mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-28 08:07:45 -06:00
Compare commits
325 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85e6370a8 | ||
|
|
0497539ef2 | ||
|
|
431361efad | ||
|
|
e82bf66a76 | ||
|
|
c8a73b5b15 | ||
|
|
b518258e6d | ||
|
|
a1d45023ab | ||
|
|
ba3ae0d80a | ||
|
|
d04727f4b5 | ||
|
|
93ce0ce670 | ||
|
|
c2573774bf | ||
|
|
6e037e91d3 | ||
|
|
d665d4d62a | ||
|
|
29d9b32b67 | ||
|
|
00d218118c | ||
|
|
02b6ffd59a | ||
|
|
aa0e4406eb | ||
|
|
786f389be8 | ||
|
|
09a03565d7 | ||
|
|
456b058462 | ||
|
|
ecaba5b32e | ||
|
|
9f4c77d6d7 | ||
|
|
1fb67b791f | ||
|
|
a26d1812c2 | ||
|
|
b6e354085e | ||
|
|
108e9722fa | ||
|
|
72cb1cbfff | ||
|
|
ed84c4b210 | ||
|
|
77518eaf69 | ||
|
|
4bd36f0ea9 | ||
|
|
b19bf791a4 | ||
|
|
f70b7cab21 | ||
|
|
b10635a9b1 | ||
|
|
302c14186a | ||
|
|
6159994552 | ||
|
|
398041c607 | ||
|
|
6ce9f8f291 | ||
|
|
c2c8a139f3 | ||
|
|
698c0decb4 | ||
|
|
ef61c70a9d | ||
|
|
97863115ba | ||
|
|
fa5493a5d8 | ||
|
|
3e9cec3e8e | ||
|
|
943ec0b64b | ||
|
|
8008015082 | ||
|
|
af54d96d30 | ||
|
|
d98aa03e9d | ||
|
|
8d4c686ae2 | ||
|
|
982b9454f8 | ||
|
|
28a2a37ed2 | ||
|
|
3f019732b3 | ||
|
|
007852a48f | ||
|
|
3474697a66 | ||
|
|
4e09b32dd9 | ||
|
|
6dde0f030a | ||
|
|
d154b4cc9e | ||
|
|
7c11fa7b50 | ||
|
|
264bf6c484 | ||
|
|
3854a9d633 | ||
|
|
82189de78e | ||
|
|
8bad3aee74 | ||
|
|
a1f624c1cc | ||
|
|
ff0a0df478 | ||
|
|
5dd2f37035 | ||
|
|
862e44e96f | ||
|
|
643b0eaf65 | ||
|
|
0af6df3121 | ||
|
|
e0616d933f | ||
|
|
1e7fdbc79a | ||
|
|
acc59a9da5 | ||
|
|
03ce4bdfca | ||
|
|
1473d90243 | ||
|
|
32eee0bede | ||
|
|
131436fc20 | ||
|
|
966c188977 | ||
|
|
afba80bff9 | ||
|
|
0d267d97fe | ||
|
|
b0cd372af9 | ||
|
|
e5af4f6f17 | ||
|
|
399a633d9d | ||
|
|
2ef223b5ea | ||
|
|
2cdb527df9 | ||
|
|
fc0e8e2aae | ||
|
|
e5454d6714 | ||
|
|
328958876a | ||
|
|
a7389de109 | ||
|
|
b911ab01d2 | ||
|
|
9153c71cbf | ||
|
|
b44aa9d32e | ||
|
|
bcb1d9af0b | ||
|
|
ef84889a57 | ||
|
|
81c027e7cf | ||
|
|
fd62a248ee | ||
|
|
2c8bea1b59 | ||
|
|
07364abf9e | ||
|
|
20cb13e1bb | ||
|
|
3f3b385de7 | ||
|
|
94b12e506e | ||
|
|
4ec6e52e73 | ||
|
|
88adc5ca86 | ||
|
|
68f73c7f94 | ||
|
|
223c95adbc | ||
|
|
3aaca1ca02 | ||
|
|
6a4d17b8a5 | ||
|
|
720c5fabaf | ||
|
|
1c5239a4d0 | ||
|
|
05b5609d86 | ||
|
|
7e92aeb7ac | ||
|
|
6e2eb15a80 | ||
|
|
0b825ac3d0 | ||
|
|
b5f1d74d6f | ||
|
|
e071b7dfd5 | ||
|
|
53e4e74930 | ||
|
|
b83de7eb11 | ||
|
|
38a208242b | ||
|
|
4acd8e180d | ||
|
|
debc8521a5 | ||
|
|
8bd268d81c | ||
|
|
ae6848b194 | ||
|
|
b22744b031 | ||
|
|
a75d7079df | ||
|
|
aa8442a345 | ||
|
|
70625a5cb0 | ||
|
|
7c043d9b4f | ||
|
|
546f17ab50 | ||
|
|
1c9986efc4 | ||
|
|
8ae13e29f5 | ||
|
|
f5bb072f28 | ||
|
|
37eef0ba6d | ||
|
|
603b80db1b | ||
|
|
8d9543cb6a | ||
|
|
c823660a8f | ||
|
|
ec4d28ac6c | ||
|
|
0c5ad85b35 | ||
|
|
bdecf7a3e3 | ||
|
|
6b62720daf | ||
|
|
d48c450018 | ||
|
|
078404fb59 | ||
|
|
4bb526896f | ||
|
|
0476006ef2 | ||
|
|
19831f0177 | ||
|
|
fc9871fba3 | ||
|
|
b34f4f8e43 | ||
|
|
0357d8522c | ||
|
|
08d06bd781 | ||
|
|
01a97add2a | ||
|
|
3cb351dceb | ||
|
|
9e11591b3b | ||
|
|
e4c1cece75 | ||
|
|
6881a98048 | ||
|
|
36de9f10d6 | ||
|
|
1cc135f01f | ||
|
|
079c8894fa | ||
|
|
957074a134 | ||
|
|
970759ed8b | ||
|
|
22f17a1424 | ||
|
|
5ed797cfc9 | ||
|
|
8ad59058a5 | ||
|
|
ec7bbcf90d | ||
|
|
37dde72c8f | ||
|
|
972f9be291 | ||
|
|
8b33b888b2 | ||
|
|
d29fd338eb | ||
|
|
c4f7e8121a | ||
|
|
8b5dba25f5 | ||
|
|
e18b5f5fd4 | ||
|
|
a5dc9537e5 | ||
|
|
3064948d8c | ||
|
|
e6bcc4a3fe | ||
|
|
6967b6bdc5 | ||
|
|
a8977a5dec | ||
|
|
b837e8ea0b | ||
|
|
110052fa0f | ||
|
|
84bb977d2e | ||
|
|
2d93c2b2da | ||
|
|
9e4f2a9614 | ||
|
|
5412a9f8ea | ||
|
|
d7177d3e05 | ||
|
|
a21bd81681 | ||
|
|
e653f35bf1 | ||
|
|
28ea06a8bc | ||
|
|
86b0491b68 | ||
|
|
c8309581be | ||
|
|
376c531fe4 | ||
|
|
b2c5bcd4f1 | ||
|
|
73c64272d8 | ||
|
|
11fe54753e | ||
|
|
69f921aea9 | ||
|
|
594ef71027 | ||
|
|
d25d8c21f6 | ||
|
|
835d13542f | ||
|
|
7f5a3fffd3 | ||
|
|
1890e710cb | ||
|
|
a9fefbec5c | ||
|
|
b96e3af6c7 | ||
|
|
12e6fe1d50 | ||
|
|
60c03a646c | ||
|
|
59dcbce417 | ||
|
|
df10fa87d3 | ||
|
|
a954406d1f | ||
|
|
e2213f458f | ||
|
|
55adcc1f0c | ||
|
|
d6eaa3d0cc | ||
|
|
25ad58d42c | ||
|
|
b61bccbb67 | ||
|
|
f1da517c84 | ||
|
|
a4019be28c | ||
|
|
36090d9f02 | ||
|
|
6b101d2c49 | ||
|
|
6436d703f5 | ||
|
|
b3243704df | ||
|
|
8bedfcfc64 | ||
|
|
e0aa2c33e9 | ||
|
|
49f268a14c | ||
|
|
2bb0e65aea | ||
|
|
8b6d731cb6 | ||
|
|
1cd629efb3 | ||
|
|
2f7f5425d8 | ||
|
|
215156c333 | ||
|
|
a5d2055c11 | ||
|
|
ffc2c564b8 | ||
|
|
16f222b0ab | ||
|
|
3edf90714a | ||
|
|
4e8fc03c2b | ||
|
|
5037283b62 | ||
|
|
f2c9135b96 | ||
|
|
7378d82bc4 | ||
|
|
ed10a99771 | ||
|
|
4df128d34e | ||
|
|
33d0db5854 | ||
|
|
4a57a554da | ||
|
|
21fe7c57d8 | ||
|
|
3bcc1429dd | ||
|
|
9a6a479452 | ||
|
|
c43487b741 | ||
|
|
6b50755a5a | ||
|
|
53998e0fff | ||
|
|
7341ae087c | ||
|
|
9ea8dca4e3 | ||
|
|
d80e64b2cc | ||
|
|
d7354f4dab | ||
|
|
8d1676db54 | ||
|
|
0714a40509 | ||
|
|
5262156e1a | ||
|
|
7ac27b59c6 | ||
|
|
fc7a43f23e | ||
|
|
d5ecfe7bef | ||
|
|
e58d1ac87e | ||
|
|
bb653e733c | ||
|
|
9c27d18d6c | ||
|
|
e5c13d2d72 | ||
|
|
b27529d927 | ||
|
|
95257114df | ||
|
|
935da0d51f | ||
|
|
78ed85943b | ||
|
|
7d87cc498a | ||
|
|
a523d25c0d | ||
|
|
d84e5d1839 | ||
|
|
063e79451f | ||
|
|
b4a842d9da | ||
|
|
ec0cb7a8bc | ||
|
|
841471104b | ||
|
|
ac71416eb9 | ||
|
|
779d685335 | ||
|
|
4d1e798c56 | ||
|
|
a598035236 | ||
|
|
50395aa821 | ||
|
|
6d9c8fd85b | ||
|
|
b65d994397 | ||
|
|
b20258c66e | ||
|
|
9984238f2a | ||
|
|
c3599bacf2 | ||
|
|
c10481b99d | ||
|
|
02e01b7386 | ||
|
|
ca7147a0a7 | ||
|
|
022c360964 | ||
|
|
d41f4d2db3 | ||
|
|
4871682dc6 | ||
|
|
1cebc1248b | ||
|
|
70d235f99e | ||
|
|
153409d37e | ||
|
|
67a30fdf91 | ||
|
|
911ce3f047 | ||
|
|
c97f7041a7 | ||
|
|
89bfb4f722 | ||
|
|
da3935ff36 | ||
|
|
06810bff91 | ||
|
|
a9af75bbd1 | ||
|
|
da2bff691b | ||
|
|
a85b3aa69f | ||
|
|
859f89101e | ||
|
|
2545912532 | ||
|
|
2fc1519bc6 | ||
|
|
be6ef15ffa | ||
|
|
5f91413023 | ||
|
|
3b801d43bc | ||
|
|
55e07c1c9a | ||
|
|
7e475511b6 | ||
|
|
ca77e4545a | ||
|
|
1c09570805 | ||
|
|
e56797737d | ||
|
|
81852de1fa | ||
|
|
fbd39da8ca | ||
|
|
db0ef95fe3 | ||
|
|
b5a51aced3 | ||
|
|
04ba57cb38 | ||
|
|
ba42ad2115 | ||
|
|
5c13382071 | ||
|
|
3df8c63d5c | ||
|
|
5d46a112f8 | ||
|
|
e01e5e6b0e | ||
|
|
4f2dc50b5c | ||
|
|
c3e5106b04 | ||
|
|
593ae295e3 | ||
|
|
198170ca48 | ||
|
|
57973f62c5 | ||
|
|
e57b8aa26f | ||
|
|
3d023126ba | ||
|
|
53f58d4496 | ||
|
|
1a6ee237f6 | ||
|
|
33a99441a4 | ||
|
|
3df7e283e3 | ||
|
|
b295849f53 | ||
|
|
c107f35118 | ||
|
|
3d91153275 |
48
.github/ISSUE_TEMPLATE.md
vendored
48
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,48 +0,0 @@
|
||||
<!--
|
||||
Before opening a new issue, please search through the existing issues to
|
||||
see if your topic has already been addressed. Note that you may need to
|
||||
remove the "is:open" filter from the search bar to include closed issues.
|
||||
|
||||
Check the appropriate type for your issue below by placing an x between the
|
||||
brackets. For assistance with installation issues, or for any other issues
|
||||
other than those listed below, please raise your topic for discussion on
|
||||
our mailing list:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
|
||||
Please note that issues which do not fall under any of the below categories
|
||||
will be closed. Due to an excessive backlog of feature requests, we are
|
||||
not currently accepting any proposals which extend NetBox's feature scope.
|
||||
|
||||
Do not prepend any sort of tag to your issue's title. An administrator will
|
||||
review your issue and assign labels as appropriate.
|
||||
--->
|
||||
### Issue type
|
||||
[ ] Feature request <!-- An enhancement of existing functionality -->
|
||||
[ ] Bug report <!-- Unexpected or erroneous behavior -->
|
||||
[ ] Documentation <!-- A modification to the documentation -->
|
||||
|
||||
<!--
|
||||
Please describe the environment in which you are running NetBox. (Be sure
|
||||
to verify that you are running the latest stable release of NetBox before
|
||||
submitting a bug report.) If you are submitting a bug report and have made
|
||||
any changes to the code base, please first validate that your bug can be
|
||||
recreated while running an official release.
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.5.4 -->
|
||||
* NetBox version: <!-- Example: 2.1.3 -->
|
||||
|
||||
<!--
|
||||
BUG REPORTS must include:
|
||||
* A list of the steps needed for someone else to reproduce the bug
|
||||
* A description of the expected and observed behavior
|
||||
* Any relevant error messages (screenshots may also help)
|
||||
|
||||
FEATURE REQUESTS must include:
|
||||
* A detailed description of the proposed functionality
|
||||
* A use case for the new feature
|
||||
* A rough description of any necessary changes to the database schema
|
||||
* Any relevant third-party libraries which would be needed
|
||||
-->
|
||||
### Description
|
||||
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: :bug: Bug Report
|
||||
about: Report a reproducible bug in the current release of NetBox
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: This form is only for reproducible bugs. If you need assistance with
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
before submitting a bug report.
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.5.4 -->
|
||||
* NetBox version: <!-- Example: 2.3.6 -->
|
||||
|
||||
<!--
|
||||
Describe in detail the steps that someone else can take to reproduce this
|
||||
bug using the current stable release of NetBox (or the current beta release
|
||||
where applicable).
|
||||
-->
|
||||
### Steps to Reproduce
|
||||
|
||||
|
||||
<!-- What did you expect to happen? -->
|
||||
### Expected Behavior
|
||||
|
||||
|
||||
<!-- What happened instead? -->
|
||||
### Observed Behavior
|
||||
17
.github/ISSUE_TEMPLATE/documentation_change.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/documentation_change.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: :book: Documentation Change
|
||||
about: Suggest an addition or modification to the NetBox documentation
|
||||
---
|
||||
|
||||
<!--
|
||||
Please indicate the nature of the change by placing an X in one of the
|
||||
boxes below.
|
||||
-->
|
||||
### Change Type
|
||||
[ ] Addition
|
||||
[ ] Correction
|
||||
[ ] Deprecation
|
||||
[ ] Cleanup (formatting, typos, etc.)
|
||||
|
||||
<!-- Describe the proposed change(s). -->
|
||||
### Proposed Changes
|
||||
53
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: :new: Feature Request
|
||||
about: Propose a new NetBox feature or enhancement
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: This form is only for proposing specific new features or enhancements.
|
||||
If you have a general idea or question, please post to our mailing list
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
|
||||
NOTE: Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which significantly extend NetBox's feature scope.
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
before submitting a bug report.
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.5.4 -->
|
||||
* NetBox version: <!-- Example: 2.3.6 -->
|
||||
|
||||
<!--
|
||||
Describe in detail the new functionality you are proposing. Include any
|
||||
specific changes to work flows, data models, or the user interface.
|
||||
-->
|
||||
### Proposed Functionality
|
||||
|
||||
|
||||
<!--
|
||||
Convey an example use case for your proposed feature. Write from the
|
||||
perspective of a NetBox user who would benefit from the proposed
|
||||
functionality and describe how.
|
||||
--->
|
||||
### Use Case
|
||||
|
||||
|
||||
<!--
|
||||
Note any changes to the database schema necessary to support the new
|
||||
feature. For example, does the proposal require adding a new model or
|
||||
field? (Not all new features require database changes.)
|
||||
--->
|
||||
### Database Changes
|
||||
|
||||
|
||||
<!--
|
||||
List any new dependencies on external libraries or services that this new
|
||||
feature would introduce. For example, does the proposal require the
|
||||
installation of a new Python package? (Not all new features introduce new
|
||||
dependencies.)
|
||||
-->
|
||||
### External Dependencies
|
||||
16
.github/ISSUE_TEMPLATE/housekeeping.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/housekeeping.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: :house: Housekeeping
|
||||
about: A change pertaining to the codebase itself
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: This type of issue should be opened only by those reasonably familiar
|
||||
with NetBox's code base and interested in contributing to its development.
|
||||
|
||||
Describe the proposed change(s) in detail.
|
||||
-->
|
||||
### Proposed Changes
|
||||
|
||||
|
||||
<!-- Provide justification for the proposed change(s). -->
|
||||
### Justification -->
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -6,6 +6,8 @@
|
||||
be able to accept.
|
||||
|
||||
Please indicate the relevant feature request or bug report below.
|
||||
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
|
||||
FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
|
||||
-->
|
||||
### Fixes:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ python:
|
||||
- "3.5"
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install pep8
|
||||
- pip install pycodestyle
|
||||
before_script:
|
||||
- psql --version
|
||||
- psql -U postgres -c 'SELECT version();'
|
||||
|
||||
@@ -10,24 +10,23 @@ We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
|
||||
### Freenode IRC
|
||||
### Slack
|
||||
|
||||
For real-time discussion, you can join the #netbox channel on [Freenode](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/).
|
||||
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
|
||||
|
||||
## 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
|
||||
* 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 mightalso 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.
|
||||
* 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 mightalso 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 the mailing list. **Do not** file an issue until you
|
||||
@@ -44,7 +43,7 @@ include:
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
|
||||
The issue will be reviewed by a moderator after submission and the appropriate
|
||||
labels will be applied.
|
||||
labels will be applied for categorization.
|
||||
|
||||
* 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
|
||||
@@ -52,15 +51,15 @@ your issue.
|
||||
|
||||
## Feature Requests
|
||||
|
||||
* 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 have been rejected.) If the feature you'd like to see has
|
||||
already been requested and is open, 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 receiving attention. Also feel free to add a comment with
|
||||
any additional justification for the feature. (However, note that comments with
|
||||
no substance other than a "+1" will be deleted. Please use GitHub's reactions
|
||||
feature to indicate your support.)
|
||||
* 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 have been rejected.) If the
|
||||
feature you'd like to see has already been requested and is open, 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 receiving attention. Also feel
|
||||
free to add a comment with any additional justification for the feature.
|
||||
(However, note that comments with no substance other than a "+1" will be
|
||||
deleted. Please use GitHub's reactions feature to indicate your support.)
|
||||
|
||||
* Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which substantially extend NetBox's functionality
|
||||
@@ -88,15 +87,17 @@ following:
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
|
||||
title. The issue will be reviewed by a moderator after submission and the
|
||||
appropriate labels will be applied.
|
||||
appropriate labels will be applied for categorization.
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
* Be sure to open an issue before starting work on a pull request, and discuss
|
||||
your idea with the NetBox maintainers 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.
|
||||
* Be sure to open an issue **before** starting work on a pull request, and
|
||||
discuss your idea with the NetBox maintainers 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.
|
||||
|
||||
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of the `develop`
|
||||
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||
@@ -109,3 +110,10 @@ these checks):
|
||||
* All tests pass when run with `./manage.py test`
|
||||
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||
greater than 80 characters in length
|
||||
|
||||
## Commenting
|
||||
|
||||
Only comment on an issue if you are sharing a relevant idea or constructive
|
||||
feedback. **Do not** comment on an issue just to show your support (give the
|
||||
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
|
||||
reduce noise in the discussion.
|
||||
|
||||
17
README.md
17
README.md
@@ -1,12 +1,18 @@
|
||||

|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
|
||||
@@ -27,9 +33,12 @@ NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended.
|
||||
|
||||
# Installation
|
||||
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases)
|
||||
and run `upgrade.sh`.
|
||||
|
||||
## Alternative Installations
|
||||
|
||||
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
|
||||
@@ -5,7 +5,7 @@ Supported HTTP methods:
|
||||
* `GET`: Retrieve an object or list of objects
|
||||
* `POST`: Create a new object
|
||||
* `PUT`: Update an existing object, all mandatory fields must be specified
|
||||
* `PATCH`: Updates an existing object, only specifiying the field to be changed
|
||||
* `PATCH`: Updates an existing object, only specifying the field to be changed
|
||||
* `DELETE`: Delete an existing object
|
||||
|
||||
To authenticate a request, attach your token in an `Authorization` header:
|
||||
@@ -82,15 +82,15 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6
|
||||
|
||||
### Creating a new site
|
||||
|
||||
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required.
|
||||
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. This example includes one non required field, "region."
|
||||
|
||||
```
|
||||
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}'
|
||||
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site", "region": 5}'
|
||||
{
|
||||
"id": 16,
|
||||
"name": "My New Site",
|
||||
"slug": "my-new-site",
|
||||
"region": null,
|
||||
"region": 5,
|
||||
"tenant": null,
|
||||
"facility": "",
|
||||
"asn": null,
|
||||
@@ -102,6 +102,7 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
|
||||
"comments": ""
|
||||
}
|
||||
```
|
||||
Note that in this example we are creating a site bound to a region with the ID of 5. For write API actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action.
|
||||
|
||||
### Modify an existing site
|
||||
|
||||
@@ -143,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
|
||||
* Closing connection 0
|
||||
```
|
||||
|
||||
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
||||
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
||||
|
||||
@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
|
||||
}
|
||||
```
|
||||
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name.
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
|
||||
|
||||
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
||||
|
||||
@@ -206,3 +206,28 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
|
||||
|
||||
!!! warning
|
||||
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
|
||||
|
||||
# Filtering
|
||||
|
||||
A list of objects retrieved via the API can be filtered by passing one or more query parameters. The same parameters used by the web UI work for the API as well. For example, to return only prefixes with a status of "Active" (`1`):
|
||||
|
||||
```
|
||||
GET /api/ipam/prefixes/?status=1
|
||||
```
|
||||
|
||||
The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
|
||||
|
||||
```
|
||||
GET /api/ipam/prefixes/?status=1&status=2
|
||||
```
|
||||
|
||||
## Custom Fields
|
||||
|
||||
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
|
||||
|
||||
```
|
||||
GET /api/dcim/sites/?cf_foo=123
|
||||
```
|
||||
|
||||
!!! note
|
||||
Full versus partial matching when filtering is configurable per custom field. Filtering can be toggled (or disabled) for a custom field in the admin UI.
|
||||
|
||||
@@ -42,6 +42,8 @@ A device type represents a particular hardware model that exists in the real wor
|
||||
|
||||
Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type.
|
||||
|
||||
A device type can be a parent, child, or neither. Parent devices house child devices in device bays. This relationship is used to model things like blade servers, where child devices function independently but share physical resources like rack space and power. Note that this is **not** intended to model chassis-based devices, wherein child members share a common control plane.
|
||||
|
||||
### Manufacturers
|
||||
|
||||
Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer.
|
||||
@@ -112,3 +114,11 @@ Console ports connect only to console server ports, and power ports connect only
|
||||
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
|
||||
|
||||
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
||||
|
||||
---
|
||||
|
||||
# Virtual Chassis
|
||||
|
||||
A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
|
||||
|
||||
It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.
|
||||
|
||||
@@ -24,7 +24,7 @@ sudo pip install django-auth-ldap
|
||||
|
||||
# Configuration
|
||||
|
||||
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`.
|
||||
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
|
||||
## General Server Configuration
|
||||
|
||||
@@ -52,6 +52,8 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
LDAP_IGNORE_CERT_ERRORS = True
|
||||
```
|
||||
|
||||
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
||||
|
||||
## User Authentication
|
||||
|
||||
!!! info
|
||||
@@ -78,14 +80,14 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
||||
```
|
||||
|
||||
# User Groups for Permissions
|
||||
!!! Info
|
||||
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
|
||||
!!! info
|
||||
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
|
||||
|
||||
```python
|
||||
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# heirarchy.
|
||||
# hierarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)")
|
||||
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
|
||||
|
||||
@@ -88,6 +88,11 @@ Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
|
||||
|
||||
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
|
||||
|
||||
## Install Python Packages
|
||||
|
||||
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
||||
|
||||
@@ -12,25 +12,37 @@ Download and extract the latest version:
|
||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -sf netbox-X.Y.Z/ netbox
|
||||
# ln -sfn netbox-X.Y.Z/ netbox
|
||||
```
|
||||
|
||||
Copy the 'configuration.py' you created when first installing to the new version:
|
||||
|
||||
```no-highlight
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
||||
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||
|
||||
```no-highlight
|
||||
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
|
||||
```
|
||||
|
||||
Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
|
||||
|
||||
```no-highlight
|
||||
# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```no-highlight
|
||||
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
|
||||
# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py
|
||||
```
|
||||
|
||||
Copy the LDAP configuration if using LDAP:
|
||||
|
||||
```no-highlight
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
|
||||
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
|
||||
```
|
||||
|
||||
## Option B: Clone the Git Repository (latest master release)
|
||||
|
||||
@@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
||||
ProxyPass !
|
||||
</Location>
|
||||
|
||||
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||
ProxyPass / http://127.0.0.1:8001/
|
||||
ProxyPassReverse / http://127.0.0.1:8001/
|
||||
</VirtualHost>
|
||||
@@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
||||
```no-highlight
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2enmod headers
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ class DeviceIPsReport(Report):
|
||||
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
||||
|
||||
```
|
||||
from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE
|
||||
from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE
|
||||
from dcim.models import ConsolePort, Device, PowerPort
|
||||
from extras.reports import Report
|
||||
|
||||
@@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report):
|
||||
def test_console_connection(self):
|
||||
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE):
|
||||
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
|
||||
if console_port.cs_port is None:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
@@ -60,7 +60,7 @@ class DeviceConnectionsReport(Report):
|
||||
def test_power_connections(self):
|
||||
|
||||
# Check that every active device has at least two connected power supplies.
|
||||
for device in Device.objects.filter(status=STATUS_ACTIVE):
|
||||
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.power_outlet is not None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
||||
NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
||||
|
||||
```
|
||||
./manage.py nbshell
|
||||
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
|
||||
982
|
||||
```
|
||||
|
||||
Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
|
||||
Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
|
||||
|
||||
```
|
||||
>>> Device.objects.filter(tenant__name='Pied Piper')
|
||||
|
||||
@@ -2,11 +2,12 @@ from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ValidatedModelSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
|
||||
|
||||
#
|
||||
@@ -19,7 +20,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
|
||||
model = Provider
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
|
||||
model = Provider
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -66,14 +67,15 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
|
||||
|
||||
class CircuitSerializer(CustomFieldModelSerializer):
|
||||
provider = NestedProviderSerializer()
|
||||
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -90,8 +92,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ from __future__ import unicode_literals
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from circuits import filters
|
||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -20,6 +19,7 @@ from . import serializers
|
||||
|
||||
class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(Circuit, ['status']),
|
||||
(CircuitTermination, ['term_side']),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.all()
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
write_serializer_class = serializers.WritableProviderSerializer
|
||||
@@ -59,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
write_serializer_class = serializers.WritableCircuitSerializer
|
||||
@@ -70,7 +70,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class CircuitTerminationViewSet(ModelViewSet):
|
||||
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
# Circuit statuses
|
||||
CIRCUIT_STATUS_DEPROVISIONING = 0
|
||||
CIRCUIT_STATUS_ACTIVE = 1
|
||||
CIRCUIT_STATUS_PLANNED = 2
|
||||
CIRCUIT_STATUS_PROVISIONING = 3
|
||||
CIRCUIT_STATUS_OFFLINE = 4
|
||||
CIRCUIT_STATUS_DECOMMISSIONED = 5
|
||||
CIRCUIT_STATUS_CHOICES = [
|
||||
[CIRCUIT_STATUS_PLANNED, 'Planned'],
|
||||
[CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
|
||||
[CIRCUIT_STATUS_ACTIVE, 'Active'],
|
||||
[CIRCUIT_STATUS_OFFLINE, 'Offline'],
|
||||
[CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
|
||||
[CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
|
||||
]
|
||||
|
||||
# CircuitTermination sides
|
||||
TERM_SIDE_A = 'A'
|
||||
TERM_SIDE_Z = 'Z'
|
||||
|
||||
@@ -7,6 +7,7 @@ from dcim.models import Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NumericInFilter
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
|
||||
|
||||
@@ -77,6 +78,10 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Circuit type (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
|
||||
@@ -8,9 +8,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
|
||||
SmallTextarea, SlugField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
|
||||
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
)
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
|
||||
@@ -43,7 +44,7 @@ class ProviderCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
|
||||
fields = Provider.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Provider name',
|
||||
'asn': '32-bit autonomous system number',
|
||||
@@ -89,7 +90,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['name', 'slug']
|
||||
fields = CircuitType.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of circuit type',
|
||||
}
|
||||
@@ -105,7 +106,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'comments',
|
||||
]
|
||||
help_texts = {
|
||||
@@ -132,6 +133,11 @@ class CircuitCSVForm(forms.ModelForm):
|
||||
'invalid_choice': 'Invalid circuit type.'
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
@@ -144,13 +150,16 @@ class CircuitCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
fields = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
@@ -171,10 +180,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
annotate=Circuit.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
|
||||
|
||||
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-06 18:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0009_unicode_literals'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1),
|
||||
),
|
||||
]
|
||||
@@ -5,12 +5,12 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from dcim.constants import STATUS_CLASSES
|
||||
from dcim.fields import ASNField
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.utils import csv_format
|
||||
from .constants import *
|
||||
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -29,7 +29,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url']
|
||||
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -41,13 +41,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
|
||||
return reverse('circuits:provider', args=[self.slug])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.asn,
|
||||
self.account,
|
||||
self.portal_url,
|
||||
])
|
||||
self.noc_contact,
|
||||
self.admin_contact,
|
||||
self.comments,
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -59,6 +62,8 @@ class CircuitType(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
csv_headers = ['name', 'slug']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -68,6 +73,12 @@ class CircuitType(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
@@ -79,6 +90,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
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)
|
||||
status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
|
||||
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||
@@ -86,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
|
||||
csv_headers = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
@@ -99,15 +113,20 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.cid,
|
||||
self.provider.name,
|
||||
self.type.name,
|
||||
self.get_status_display(),
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.install_date.isoformat() if self.install_date else None,
|
||||
self.install_date,
|
||||
self.commit_rate,
|
||||
self.description,
|
||||
])
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
def _get_termination(self, side):
|
||||
for ct in self.terminations.all():
|
||||
|
||||
@@ -4,6 +4,7 @@ import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
@@ -13,6 +14,10 @@ CIRCUITTYPE_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
class CircuitTerminationColumn(tables.Column):
|
||||
|
||||
@@ -75,10 +80,11 @@ class CircuitTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
cid = tables.LinkColumn(verbose_name='ID')
|
||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
|
||||
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Circuit
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 4)
|
||||
@@ -77,6 +77,32 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(provider4.name, data['name'])
|
||||
self.assertEqual(provider4.slug, data['slug'])
|
||||
|
||||
def test_create_provider_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Provider 4',
|
||||
'slug': 'test-provider-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 5',
|
||||
'slug': 'test-provider-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 6',
|
||||
'slug': 'test-provider-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_provider(self):
|
||||
|
||||
data = {
|
||||
@@ -85,7 +111,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Provider.objects.count(), 3)
|
||||
@@ -136,7 +162,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitType.objects.count(), 4)
|
||||
@@ -152,7 +178,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitType.objects.count(), 3)
|
||||
@@ -208,7 +234,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 4)
|
||||
@@ -217,6 +243,35 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(circuit4.provider_id, data['provider'])
|
||||
self.assertEqual(circuit4.type_id, data['type'])
|
||||
|
||||
def test_create_circuit_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'cid': 'TEST0004',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0005',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0006',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
|
||||
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
|
||||
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
|
||||
|
||||
def test_update_circuit(self):
|
||||
|
||||
data = {
|
||||
@@ -226,7 +281,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Circuit.objects.count(), 3)
|
||||
@@ -293,7 +348,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 4)
|
||||
@@ -313,7 +368,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||
|
||||
@@ -7,19 +7,20 @@ from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from dcim.constants import (
|
||||
CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
|
||||
RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||
CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
|
||||
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from users.api.serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
|
||||
from virtualization.models import Cluster
|
||||
|
||||
|
||||
@@ -55,15 +56,18 @@ class WritableRegionSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class SiteSerializer(CustomFieldModelSerializer):
|
||||
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
|
||||
region = NestedRegionSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
time_zone = TimeZoneField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
||||
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
|
||||
'count_circuits',
|
||||
]
|
||||
|
||||
|
||||
@@ -76,12 +80,14 @@ class NestedSiteSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class WritableSiteSerializer(CustomFieldModelSerializer):
|
||||
time_zone = TimeZoneField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
|
||||
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -147,7 +153,7 @@ class RackSerializer(CustomFieldModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields',
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -165,7 +171,7 @@ class WritableRackSerializer(CustomFieldModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
||||
'desc_units', 'comments', 'custom_fields',
|
||||
'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||
# prevents facility_id from being interpreted as a required field.
|
||||
@@ -215,17 +221,19 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
|
||||
class RackReservationSerializer(serializers.ModelSerializer):
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
|
||||
|
||||
|
||||
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'user', 'description']
|
||||
fields = ['id', 'rack', 'units', 'user', 'tenant', 'description']
|
||||
|
||||
|
||||
#
|
||||
@@ -423,11 +431,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformSerializer(ValidatedModelSerializer):
|
||||
class PlatformSerializer(serializers.ModelSerializer):
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
|
||||
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||
@@ -438,6 +447,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritablePlatformSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
@@ -460,6 +476,16 @@ class NestedClusterSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
# Cannot import NestedVirtualChassisSerializer due to circular dependency
|
||||
class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'master']
|
||||
|
||||
|
||||
class DeviceSerializer(CustomFieldModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
@@ -468,19 +494,21 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
rack = NestedRackSerializer()
|
||||
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
|
||||
primary_ip = DeviceIPAddressSerializer()
|
||||
primary_ip4 = DeviceIPAddressSerializer()
|
||||
primary_ip6 = DeviceIPAddressSerializer()
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
cluster = NestedClusterSerializer()
|
||||
virtual_chassis = DeviceVirtualChassisSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'comments', 'custom_fields',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
@@ -500,7 +528,8 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -628,6 +657,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
|
||||
class InterfaceVLANSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
@@ -635,12 +673,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
||||
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||
interface_connection = serializers.SerializerMethodField(read_only=True)
|
||||
circuit_termination = InterfaceCircuitTerminationSerializer()
|
||||
untagged_vlan = InterfaceVLANSerializer()
|
||||
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
|
||||
tagged_vlans = InterfaceVLANSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'is_connected', 'interface_connection', 'circuit_termination',
|
||||
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
|
||||
def get_is_connected(self, obj):
|
||||
@@ -685,8 +726,28 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# All associated VLANs be global or assigned to the parent device's site.
|
||||
device = self.instance.device if self.instance else data.get('device')
|
||||
untagged_vlan = data.get('untagged_vlan')
|
||||
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
|
||||
"global.".format(untagged_vlan)
|
||||
})
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||
"be global.".format(vlan)
|
||||
})
|
||||
|
||||
return super(WritableInterfaceSerializer, self).validate(data)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
@@ -733,6 +794,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class WritableInventoryItemSerializer(ValidatedModelSerializer):
|
||||
# Provide a default value to satisfy UniqueTogetherValidator
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
@@ -769,3 +832,30 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(serializers.ModelSerializer):
|
||||
master = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain']
|
||||
|
||||
|
||||
class NestedVirtualChassisSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url']
|
||||
|
||||
|
||||
class WritableVirtualChassisSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain']
|
||||
|
||||
@@ -60,6 +60,9 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
|
||||
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
||||
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||
|
||||
# Virtual chassis
|
||||
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
|
||||
|
||||
# Miscellaneous
|
||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||
|
||||
|
||||
@@ -5,24 +5,25 @@ from collections import OrderedDict
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.openapi import Parameter
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
|
||||
from dcim import filters
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from utilities.api import (
|
||||
IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin,
|
||||
)
|
||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
|
||||
@@ -35,11 +36,12 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(Device, ['face', 'status']),
|
||||
(ConsolePort, ['connection_status']),
|
||||
(Interface, ['form_factor']),
|
||||
(Interface, ['form_factor', 'mode']),
|
||||
(InterfaceConnection, ['connection_status']),
|
||||
(InterfaceTemplate, ['form_factor']),
|
||||
(PowerPort, ['connection_status']),
|
||||
(Rack, ['type', 'width']),
|
||||
(Site, ['status']),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +49,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class RegionViewSet(ModelViewSet):
|
||||
queryset = Region.objects.all()
|
||||
serializer_class = serializers.RegionSerializer
|
||||
write_serializer_class = serializers.WritableRegionSerializer
|
||||
@@ -58,7 +60,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.select_related('region', 'tenant')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
write_serializer_class = serializers.WritableSiteSerializer
|
||||
@@ -79,7 +81,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class RackGroupViewSet(ModelViewSet):
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
write_serializer_class = serializers.WritableRackGroupSerializer
|
||||
@@ -100,7 +102,7 @@ class RackRoleViewSet(ModelViewSet):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||
serializer_class = serializers.RackSerializer
|
||||
write_serializer_class = serializers.WritableRackSerializer
|
||||
@@ -131,8 +133,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = RackReservation.objects.select_related('rack')
|
||||
class RackReservationViewSet(ModelViewSet):
|
||||
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
|
||||
serializer_class = serializers.RackReservationSerializer
|
||||
write_serializer_class = serializers.WritableRackReservationSerializer
|
||||
filter_class = filters.RackReservationFilter
|
||||
@@ -156,7 +158,7 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
||||
@@ -167,42 +169,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsolePortTemplateViewSet(ModelViewSet):
|
||||
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
||||
filter_class = filters.ConsolePortTemplateFilter
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsoleServerPortTemplateViewSet(ModelViewSet):
|
||||
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
||||
filter_class = filters.ConsoleServerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerPortTemplateViewSet(ModelViewSet):
|
||||
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
||||
filter_class = filters.PowerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerOutletTemplateViewSet(ModelViewSet):
|
||||
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
||||
filter_class = filters.PowerOutletTemplateFilter
|
||||
|
||||
|
||||
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceTemplateViewSet(ModelViewSet):
|
||||
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.InterfaceTemplateSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
||||
filter_class = filters.InterfaceTemplateFilter
|
||||
|
||||
|
||||
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
||||
@@ -226,6 +228,7 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
write_serializer_class = serializers.WritablePlatformSerializer
|
||||
filter_class = filters.PlatformFilter
|
||||
|
||||
|
||||
@@ -233,9 +236,10 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class DeviceViewSet(CustomFieldModelViewSet):
|
||||
queryset = Device.objects.select_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master',
|
||||
).prefetch_related(
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||
)
|
||||
@@ -263,12 +267,7 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
import napalm
|
||||
except ImportError:
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
|
||||
# TODO: Remove support for NAPALM < 2.0
|
||||
try:
|
||||
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
|
||||
except ImportError:
|
||||
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
|
||||
from napalm.base.exceptions import ModuleImportError
|
||||
|
||||
# Validate the configured driver
|
||||
try:
|
||||
@@ -282,16 +281,8 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
if not request.user.has_perm('dcim.napalm_read'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
# Validate requested NAPALM methods
|
||||
# Connect to the device
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
for method in napalm_methods:
|
||||
if not hasattr(driver, method):
|
||||
return HttpResponseBadRequest("Unknown NAPALM method: {}".format(method))
|
||||
elif not method.startswith('get_'):
|
||||
return HttpResponseBadRequest("Unsupported NAPALM method: {}".format(method))
|
||||
|
||||
# Connect to the device and execute the requested methods
|
||||
# TODO: Improve error handling
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
ip_address = str(device.primary_ip.address.ip)
|
||||
d = driver(
|
||||
@@ -303,12 +294,23 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
)
|
||||
try:
|
||||
d.open()
|
||||
for method in napalm_methods:
|
||||
response[method] = getattr(d, method)()
|
||||
except Exception as e:
|
||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e))
|
||||
|
||||
# Validate and execute each specified NAPALM method
|
||||
for method in napalm_methods:
|
||||
if not hasattr(driver, method):
|
||||
response[method] = {'error': 'Unknown NAPALM method'}
|
||||
continue
|
||||
if not method.startswith('get_'):
|
||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||
continue
|
||||
try:
|
||||
response[method] = getattr(d, method)()
|
||||
except NotImplementedError:
|
||||
response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)}
|
||||
d.close()
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
@@ -316,35 +318,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsolePortViewSet(ModelViewSet):
|
||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortSerializer
|
||||
filter_class = filters.ConsolePortFilter
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsoleServerPortViewSet(ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
||||
filter_class = filters.ConsoleServerPortFilter
|
||||
|
||||
|
||||
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerPortViewSet(ModelViewSet):
|
||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortSerializer
|
||||
filter_class = filters.PowerPortFilter
|
||||
|
||||
|
||||
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerOutletViewSet(ModelViewSet):
|
||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletSerializer
|
||||
filter_class = filters.PowerOutletFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.select_related('device')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||
@@ -361,14 +363,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
queryset = DeviceBay.objects.select_related('installed_device')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
write_serializer_class = serializers.WritableDeviceBaySerializer
|
||||
filter_class = filters.DeviceBayFilter
|
||||
|
||||
|
||||
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
write_serializer_class = serializers.WritableInventoryItemSerializer
|
||||
@@ -391,13 +393,23 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
filter_class = filters.PowerConnectionFilter
|
||||
|
||||
|
||||
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceConnectionViewSet(ModelViewSet):
|
||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||
filter_class = filters.InterfaceConnectionFilter
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
write_serializer_class = serializers.WritableVirtualChassisSerializer
|
||||
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
#
|
||||
@@ -412,14 +424,20 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
* `peer-interface`: The name of the peer interface
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
_device_param = Parameter('peer-device', 'query',
|
||||
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
|
||||
_interface_param = Parameter('peer-interface', 'query',
|
||||
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
|
||||
|
||||
def get_view_name(self):
|
||||
return "Connected Device Locator"
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
|
||||
def list(self, request):
|
||||
|
||||
peer_device_name = request.query_params.get('peer-device')
|
||||
peer_interface_name = request.query_params.get('peer-interface')
|
||||
peer_device_name = request.query_params.get(self._device_param.name)
|
||||
peer_interface_name = request.query_params.get(self._interface_param.name)
|
||||
if not peer_device_name or not peer_interface_name:
|
||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ from django.apps import AppConfig
|
||||
class DCIMConfig(AppConfig):
|
||||
name = "dcim"
|
||||
verbose_name = "DCIM"
|
||||
|
||||
def ready(self):
|
||||
import dcim.signals
|
||||
|
||||
@@ -193,24 +193,43 @@ WIRELESS_IFACE_TYPES = [
|
||||
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
|
||||
# Device statuses
|
||||
STATUS_OFFLINE = 0
|
||||
STATUS_ACTIVE = 1
|
||||
STATUS_PLANNED = 2
|
||||
STATUS_STAGED = 3
|
||||
STATUS_FAILED = 4
|
||||
STATUS_INVENTORY = 5
|
||||
STATUS_CHOICES = [
|
||||
[STATUS_ACTIVE, 'Active'],
|
||||
[STATUS_OFFLINE, 'Offline'],
|
||||
[STATUS_PLANNED, 'Planned'],
|
||||
[STATUS_STAGED, 'Staged'],
|
||||
[STATUS_FAILED, 'Failed'],
|
||||
[STATUS_INVENTORY, 'Inventory'],
|
||||
IFACE_MODE_ACCESS = 100
|
||||
IFACE_MODE_TAGGED = 200
|
||||
IFACE_MODE_TAGGED_ALL = 300
|
||||
IFACE_MODE_CHOICES = [
|
||||
[IFACE_MODE_ACCESS, 'Access'],
|
||||
[IFACE_MODE_TAGGED, 'Tagged'],
|
||||
[IFACE_MODE_TAGGED_ALL, 'Tagged All'],
|
||||
]
|
||||
|
||||
# Bootstrap CSS classes for device stasuses
|
||||
DEVICE_STATUS_CLASSES = {
|
||||
# Device statuses
|
||||
DEVICE_STATUS_OFFLINE = 0
|
||||
DEVICE_STATUS_ACTIVE = 1
|
||||
DEVICE_STATUS_PLANNED = 2
|
||||
DEVICE_STATUS_STAGED = 3
|
||||
DEVICE_STATUS_FAILED = 4
|
||||
DEVICE_STATUS_INVENTORY = 5
|
||||
DEVICE_STATUS_CHOICES = [
|
||||
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||
[DEVICE_STATUS_PLANNED, 'Planned'],
|
||||
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||
[DEVICE_STATUS_FAILED, 'Failed'],
|
||||
[DEVICE_STATUS_INVENTORY, 'Inventory'],
|
||||
]
|
||||
|
||||
# Site statuses
|
||||
SITE_STATUS_ACTIVE = 1
|
||||
SITE_STATUS_PLANNED = 2
|
||||
SITE_STATUS_RETIRED = 4
|
||||
SITE_STATUS_CHOICES = [
|
||||
[SITE_STATUS_ACTIVE, 'Active'],
|
||||
[SITE_STATUS_PLANNED, 'Planned'],
|
||||
[SITE_STATUS_RETIRED, 'Retired'],
|
||||
]
|
||||
|
||||
# Bootstrap CSS classes for device statuses
|
||||
STATUS_CLASSES = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'info',
|
||||
|
||||
@@ -11,17 +11,22 @@ from tenancy.models import Tenant
|
||||
from utilities.filters import NullableCharFieldFilter, NumericInFilter
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, STATUS_CHOICES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
|
||||
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
|
||||
WIRELESS_IFACE_TYPES,
|
||||
)
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
|
||||
|
||||
class RegionFilter(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Parent region (ID)',
|
||||
@@ -37,6 +42,15 @@ class RegionFilter(django_filters.FilterSet):
|
||||
model = Region
|
||||
fields = ['name', 'slug']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(slug__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
@@ -44,6 +58,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region (ID)',
|
||||
@@ -75,6 +93,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(facility__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(physical_address__icontains=value) |
|
||||
Q(shipping_address__icontains=value) |
|
||||
Q(contact_name__icontains=value) |
|
||||
@@ -163,7 +182,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['serial', 'type', 'width', 'u_height', 'desc_units']
|
||||
fields = ['name', 'serial', 'type', 'width', 'u_height', 'desc_units']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -208,6 +227,16 @@ class RackReservationFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
name='tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label='User (ID)',
|
||||
@@ -330,10 +359,21 @@ class DeviceRoleFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['name', 'slug', 'color']
|
||||
fields = ['name', 'slug', 'color', 'vm_role']
|
||||
|
||||
|
||||
class PlatformFilter(django_filters.FilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
label='Manufacturer (ID)',
|
||||
)
|
||||
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer__slug',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
@@ -425,7 +465,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Device model (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=STATUS_CHOICES,
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
is_full_depth = django_filters.BooleanFilter(
|
||||
@@ -452,10 +492,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
label='Virtual chassis (ID)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['serial']
|
||||
fields = ['serial', 'position']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -464,7 +509,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
Q(name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventory_items__serial__icontains=value.strip()) |
|
||||
Q(asset_tag=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
@@ -567,8 +612,9 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||
ordering = device.device_type.interface_ordering
|
||||
return queryset.filter(device=device).order_naturally(ordering)
|
||||
return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
@@ -600,6 +646,10 @@ class DeviceBayFilter(DeviceComponentFilterSet):
|
||||
|
||||
|
||||
class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
label='Parent inventory item (ID)',
|
||||
@@ -618,7 +668,61 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['name', 'part_id', 'serial', 'discovered']
|
||||
fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(part_id__icontains=value) |
|
||||
Q(serial__iexact=value) |
|
||||
Q(asset_tag__iexact=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class VirtualChassisFilter(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['domain']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(master__name__icontains=value) |
|
||||
Q(domain__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
|
||||
@@ -7,32 +7,40 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.forms.array import SimpleArrayField
|
||||
from django.db.models import Count, Q
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
from timezone_field import TimeZoneFormField
|
||||
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLAN, VLANGroup
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
|
||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
||||
SlugField, FilterTreeNodeMultipleChoiceField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES,
|
||||
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, STATUS_CHOICES,
|
||||
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
|
||||
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
|
||||
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
|
||||
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from .formfields import MACAddressFormField
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
|
||||
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||
RackRole, Region, Site,
|
||||
RackRole, Region, Site, VirtualChassis
|
||||
)
|
||||
|
||||
DEVICE_BY_PK_RE = '{\d+\}'
|
||||
DEVICE_BY_PK_RE = r'{\d+\}'
|
||||
|
||||
INTERFACE_MODE_HELP_TEXT = """
|
||||
Access: One untagged VLAN<br />
|
||||
Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
|
||||
Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
|
||||
"""
|
||||
|
||||
|
||||
def get_device_by_name_or_pk(name):
|
||||
@@ -47,6 +55,14 @@ def get_device_by_name_or_pk(name):
|
||||
return device
|
||||
|
||||
|
||||
class BulkRenameForm(forms.Form):
|
||||
"""
|
||||
An extendable form to be used for renaming device components in bulk.
|
||||
"""
|
||||
find = forms.CharField()
|
||||
replace = forms.CharField()
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
@@ -72,15 +88,18 @@ class RegionCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = [
|
||||
'name', 'slug', 'parent',
|
||||
]
|
||||
fields = Region.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Region name',
|
||||
'slug': 'URL-friendly slug',
|
||||
}
|
||||
|
||||
|
||||
class RegionFilterForm(BootstrapMixin, forms.Form):
|
||||
model = Site
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
@@ -93,8 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
|
||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -104,12 +123,19 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
'name': "Full name of the site",
|
||||
'facility': "Data center provider and facility (e.g. Equinix NY7)",
|
||||
'asn': "BGP autonomous system number",
|
||||
'time_zone': "Local time zone",
|
||||
'description': "Short description (will appear in sites list)",
|
||||
'physical_address': "Physical location of the building (e.g. for GPS)",
|
||||
'shipping_address': "If different from the physical address"
|
||||
}
|
||||
|
||||
|
||||
class SiteCSVForm(forms.ModelForm):
|
||||
status = CSVChoiceField(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
region = forms.ModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -131,10 +157,7 @@ class SiteCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
fields = Site.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Site name',
|
||||
'slug': 'URL-friendly slug',
|
||||
@@ -143,18 +166,51 @@ class SiteCSVForm(forms.ModelForm):
|
||||
|
||||
|
||||
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(SITE_STATUS_CHOICES),
|
||||
required=False,
|
||||
initial=''
|
||||
)
|
||||
region = TreeNodeChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=4294967295,
|
||||
required=False,
|
||||
label='ASN'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
time_zone = TimeZoneFormField(
|
||||
choices=add_blank_choice(TimeZoneFormField().choices),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['region', 'tenant', 'asn']
|
||||
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
annotate=Site.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||
to_field_name='slug',
|
||||
@@ -163,7 +219,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('sites')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
@@ -191,9 +247,7 @@ class RackGroupCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = [
|
||||
'site', 'name', 'slug',
|
||||
]
|
||||
fields = RackGroup.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of rack group',
|
||||
'slug': 'URL-friendly slug',
|
||||
@@ -221,7 +275,7 @@ class RackRoleCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['name', 'slug', 'color']
|
||||
fields = RackRole.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of rack role',
|
||||
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
|
||||
@@ -308,10 +362,7 @@ class RackCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
||||
'desc_units',
|
||||
]
|
||||
fields = Rack.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Rack name',
|
||||
'u_height': 'Height in rack units',
|
||||
@@ -359,17 +410,17 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')),
|
||||
label='Rack group',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('racks')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=RackRole.objects.annotate(filter_count=Count('racks')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
@@ -377,13 +428,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, forms.ModelForm):
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
|
||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['units', 'user', 'description']
|
||||
fields = ['units', 'user', 'tenant_group', 'tenant', 'description']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -411,13 +462,19 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
|
||||
label='Rack group',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -439,9 +496,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
|
||||
class ManufacturerCSVForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
'name', 'slug'
|
||||
]
|
||||
fields = Manufacturer.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Manufacturer name',
|
||||
'slug': 'URL-friendly slug',
|
||||
@@ -487,8 +542,7 @@ class DeviceTypeCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
|
||||
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
|
||||
fields = DeviceType.csv_headers
|
||||
help_texts = {
|
||||
'model': 'Model name',
|
||||
'slug': 'URL-friendly slug',
|
||||
@@ -653,7 +707,7 @@ class DeviceRoleCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['name', 'slug', 'color', 'vm_role']
|
||||
fields = DeviceRole.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of device role',
|
||||
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
|
||||
@@ -669,15 +723,24 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
|
||||
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
class PlatformCSVForm(forms.ModelForm):
|
||||
slug = SlugField()
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Manufacturer name',
|
||||
error_messages={
|
||||
'invalid_choice': 'Manufacturer not found.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['name', 'slug', 'napalm_driver']
|
||||
fields = Platform.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Platform name',
|
||||
}
|
||||
@@ -765,32 +828,35 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
|
||||
interface_ids = self.instance.vc_interfaces.values('pk')
|
||||
|
||||
# Collect interface IPs
|
||||
interface_ips = IPAddress.objects.select_related('interface').filter(
|
||||
family=family, interface__device=self.instance
|
||||
family=family, interface_id__in=interface_ids
|
||||
)
|
||||
if interface_ips:
|
||||
ip_choices.append(
|
||||
('Interface IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
ip_choices.append(('Interface IPs', ip_list))
|
||||
# Collect NAT IPs
|
||||
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
|
||||
family=family, nat_inside__interface__device=self.instance
|
||||
family=family, nat_inside__interface__in=interface_ids
|
||||
)
|
||||
if nat_ips:
|
||||
ip_choices.append(
|
||||
('NAT IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
|
||||
# can be flipped from one face to another.
|
||||
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
|
||||
|
||||
# Limit platform by manufacturer
|
||||
self.fields['platform'].queryset = Platform.objects.filter(
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
@@ -803,10 +869,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack'])\
|
||||
position_choices = Rack.objects.get(pk=self.data['rack']) \
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
elif self.initial.get('rack') and str(self.initial.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack'])\
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack']) \
|
||||
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||
else:
|
||||
position_choices = []
|
||||
@@ -866,8 +932,8 @@ class BaseDeviceCSVForm(forms.ModelForm):
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=STATUS_CHOICES,
|
||||
help_text='Operational status of device'
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
help_text='Operational status'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -927,7 +993,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
|
||||
class Meta(BaseDeviceCSVForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
|
||||
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
@@ -976,7 +1042,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
class Meta(BaseDeviceCSVForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'parent', 'device_bay_name', 'cluster',
|
||||
'parent', 'device_bay_name', 'cluster', 'comments',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
@@ -1003,20 +1069,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
|
||||
status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='')
|
||||
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['tenant', 'platform', 'serial']
|
||||
|
||||
|
||||
def device_status_choices():
|
||||
status_counts = {}
|
||||
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -1031,7 +1090,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
rack_id = FilterChoiceField(
|
||||
queryset=Rack.objects.annotate(filter_count=Count('devices')),
|
||||
label='Rack',
|
||||
null_option=(0, 'None'),
|
||||
null_label='-- None --',
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
|
||||
@@ -1040,7 +1099,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('devices')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
null_label='-- None --',
|
||||
)
|
||||
manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
|
||||
device_type_id = FilterChoiceField(
|
||||
@@ -1052,10 +1111,24 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
platform = FilterChoiceField(
|
||||
queryset=Platform.objects.annotate(filter_count=Count('devices')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
null_label='-- None --',
|
||||
)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
annotate=Device.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
|
||||
mac_address = forms.CharField(required=False, label='MAC address')
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a primary IP',
|
||||
widget=forms.Select(choices=[
|
||||
('', '---------'),
|
||||
('True', 'Yes'),
|
||||
('False', 'No'),
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1332,6 +1405,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
@@ -1593,6 +1670,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
@@ -1605,34 +1686,151 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description']
|
||||
fields = [
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
labels = {
|
||||
'mode': '802.1Q Mode',
|
||||
}
|
||||
help_texts = {
|
||||
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
if self.is_bound:
|
||||
device = Device.objects.get(pk=self.data['device'])
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device_id=self.data['device'], form_factor=IFACE_FF_LAG
|
||||
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
device = self.instance.device
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.instance.device, form_factor=IFACE_FF_LAG
|
||||
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
super(InterfaceForm, self).clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
|
||||
raise forms.ValidationError({
|
||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||
})
|
||||
|
||||
# Remove all tagged VLAN assignments from "tagged all" interfaces
|
||||
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
|
||||
|
||||
class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
|
||||
vlans = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='VLANs',
|
||||
widget=forms.SelectMultiple(attrs={'size': 20})
|
||||
)
|
||||
tagged = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.mode == IFACE_MODE_ACCESS:
|
||||
self.initial['tagged'] = False
|
||||
|
||||
# Find all VLANs already assigned to the interface for exclusion from the list
|
||||
assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
|
||||
if self.instance.untagged_vlan is not None:
|
||||
assigned_vlans.append(self.instance.untagged_vlan.pk)
|
||||
|
||||
# Compile VLAN choices
|
||||
vlan_choices = []
|
||||
|
||||
# Add global VLANs
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
|
||||
# Add grouped global VLANs
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
|
||||
)
|
||||
|
||||
parent = self.instance.parent
|
||||
if parent is not None:
|
||||
|
||||
# Add site VLANs
|
||||
if parent.site:
|
||||
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=parent.site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['vlans'].choices = vlan_choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).clean()
|
||||
|
||||
# Only untagged VLANs permitted on an access interface
|
||||
if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
|
||||
|
||||
# 'tagged' is required if more than one VLAN is selected
|
||||
if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one untagged VLAN may be selected.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
if self.cleaned_data['tagged']:
|
||||
for vlan in self.cleaned_data['vlans']:
|
||||
self.instance.tagged_vlans.add(vlan)
|
||||
else:
|
||||
self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
|
||||
|
||||
return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mac_address = MACAddressFormField(required=False, label='MAC Address')
|
||||
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
|
||||
mgmt_only = forms.BooleanField(
|
||||
required=False,
|
||||
label='OOB Management',
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1642,10 +1840,10 @@ class InterfaceCreateForm(ComponentForm):
|
||||
|
||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
# Limit LAG choices to interfaces belonging to this device (or its VC master)
|
||||
if self.parent is not None:
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.parent, form_factor=IFACE_FF_LAG
|
||||
device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
@@ -1653,36 +1851,35 @@ class InterfaceCreateForm(ComponentForm):
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['lag', 'mtu', 'description']
|
||||
nullable_fields = ['lag', 'mtu', 'description', 'mode']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device.
|
||||
device = None
|
||||
if self.initial.get('device'):
|
||||
try:
|
||||
device = Device.objects.get(pk=self.initial.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||
device = self.parent_obj
|
||||
if device is not None:
|
||||
interface_ordering = device.device_type.interface_ordering
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||
device=device, form_factor=IFACE_FF_LAG
|
||||
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
|
||||
|
||||
class InterfaceBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class InterfaceBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
@@ -1763,7 +1960,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
||||
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize interface A choices
|
||||
device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
|
||||
device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
self.fields['interface_a'].choices = [
|
||||
@@ -1772,9 +1969,11 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
||||
|
||||
# Mark connected interfaces as disabled
|
||||
if self.data.get('device_b'):
|
||||
self.fields['interface_b'].choices = [
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
|
||||
]
|
||||
self.fields['interface_b'].choices = []
|
||||
for iface in self.fields['interface_b'].queryset:
|
||||
self.fields['interface_b'].choices.append(
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected})
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||
@@ -1803,7 +2002,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
|
||||
fields = InterfaceConnection.csv_headers
|
||||
|
||||
def clean_interface_a(self):
|
||||
|
||||
@@ -1852,11 +2051,6 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||
return interface
|
||||
|
||||
|
||||
class InterfaceConnectionDeletionForm(ConfirmationForm):
|
||||
# Used for HTTP redirect upon successful deletion
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
@@ -1895,6 +2089,10 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Connections
|
||||
#
|
||||
@@ -1923,3 +2121,173 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
|
||||
|
||||
|
||||
class InventoryItemCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Device name or ID',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Manufacturer name',
|
||||
error_messages={
|
||||
'invalid_choice': 'Invalid manufacturer.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = InventoryItem.csv_headers
|
||||
|
||||
|
||||
class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
|
||||
part_id = forms.CharField(max_length=50, required=False, label='Part ID')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['manufacturer', 'part_id', 'description']
|
||||
|
||||
|
||||
class InventoryItemFilterForm(BootstrapMixin, forms.Form):
|
||||
model = InventoryItem
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
manufacturer = FilterChoiceField(
|
||||
queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class DeviceSelectionForm(forms.Form):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['master', 'domain']
|
||||
widgets = {
|
||||
'master': SelectWithPK,
|
||||
}
|
||||
|
||||
|
||||
class BaseVCMemberFormSet(forms.BaseModelFormSet):
|
||||
|
||||
def clean(self):
|
||||
super(BaseVCMemberFormSet, self).clean()
|
||||
|
||||
# Check for duplicate VC position values
|
||||
vc_position_list = []
|
||||
for form in self.forms:
|
||||
vc_position = form.cleaned_data.get('vc_position')
|
||||
if vc_position:
|
||||
if vc_position in vc_position_list:
|
||||
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
form.add_error('vc_position', error_msg)
|
||||
vc_position_list.append(vc_position)
|
||||
|
||||
|
||||
class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['vc_position', 'vc_priority']
|
||||
labels = {
|
||||
'vc_position': 'Position',
|
||||
'vc_priority': 'Priority',
|
||||
}
|
||||
|
||||
def __init__(self, validate_vc_position=False, *args, **kwargs):
|
||||
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Require VC position (only required when the Device is a VirtualChassis member)
|
||||
self.fields['vc_position'].required = True
|
||||
|
||||
# Validation of vc_position is optional. This is only required when adding a new member to an existing
|
||||
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
|
||||
self.validate_vc_position = validate_vc_position
|
||||
|
||||
def clean_vc_position(self):
|
||||
vc_position = self.cleaned_data['vc_position']
|
||||
|
||||
if self.validate_vc_position:
|
||||
conflicting_members = Device.objects.filter(
|
||||
virtual_chassis=self.instance.virtual_chassis,
|
||||
vc_position=vc_position
|
||||
)
|
||||
if conflicting_members.exists():
|
||||
raise forms.ValidationError(
|
||||
'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
)
|
||||
|
||||
return vc_position
|
||||
|
||||
|
||||
class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.filter(virtual_chassis__isnull=True),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
|
||||
display_field='display_name',
|
||||
disabled_indicator='virtual_chassis'
|
||||
)
|
||||
)
|
||||
|
||||
def clean_device(self):
|
||||
device = self.cleaned_data['device']
|
||||
if device.virtual_chassis is not None:
|
||||
raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
|
||||
return device
|
||||
|
||||
|
||||
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VirtualChassis
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
)
|
||||
|
||||
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-10 20:10
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0020_ipaddress_add_role_carp'),
|
||||
('dcim', '0049_rackreservation_change_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='mode',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='tagged_vlans',
|
||||
field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='untagged_vlan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
|
||||
),
|
||||
]
|
||||
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-15 18:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0003_unicode_literals'),
|
||||
('dcim', '0050_interface_vlan_tagging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rackreservation',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-27 17:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0051_rackreservation_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VirtualChassis',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(blank=True, max_length=30)),
|
||||
('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='virtual_chassis',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='vc_position',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='vc_priority',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='device',
|
||||
unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
|
||||
),
|
||||
]
|
||||
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-12-19 20:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0052_virtual_chassis'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='manufacturer',
|
||||
field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='napalm_driver',
|
||||
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2018-01-25 18:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import timezone_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0053_platform_manufacturer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='time_zone',
|
||||
field=timezone_field.fields.TimeZoneField(blank=True),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-21 14:41
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0054_site_status_timezone_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='virtualchassis',
|
||||
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualchassis',
|
||||
name='master',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from circuits.models import Circuit
|
||||
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
|
||||
@@ -22,7 +23,6 @@ from tenancy.models import Tenant
|
||||
from utilities.fields import ColorField, NullableCharField
|
||||
from utilities.managers import NaturalOrderByManager
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.utils import csv_format
|
||||
from .constants import *
|
||||
from .fields import ASNField, MACAddressField
|
||||
from .querysets import InterfaceQuerySet
|
||||
@@ -43,9 +43,7 @@ class Region(MPTTModel):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'parent',
|
||||
]
|
||||
csv_headers = ['name', 'slug', 'parent']
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ['name']
|
||||
@@ -57,11 +55,11 @@ class Region(MPTTModel):
|
||||
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.parent.name if self.parent else None,
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -82,10 +80,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
|
||||
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
|
||||
facility = models.CharField(max_length=50, blank=True)
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
time_zone = TimeZoneField(blank=True)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
physical_address = models.CharField(max_length=200, blank=True)
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
contact_name = models.CharField(max_length=50, blank=True)
|
||||
@@ -98,7 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
objects = SiteManager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
|
||||
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -111,17 +113,26 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
return reverse('dcim:site', args=[self.slug])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.get_status_display(),
|
||||
self.region.name if self.region else None,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.facility,
|
||||
self.asn,
|
||||
self.time_zone,
|
||||
self.description,
|
||||
self.physical_address,
|
||||
self.shipping_address,
|
||||
self.contact_name,
|
||||
self.contact_phone,
|
||||
self.contact_email,
|
||||
])
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
@property
|
||||
def count_prefixes(self):
|
||||
@@ -164,9 +175,7 @@ class RackGroup(models.Model):
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
|
||||
|
||||
csv_headers = [
|
||||
'site', 'name', 'slug',
|
||||
]
|
||||
csv_headers = ['site', 'name', 'slug']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -182,11 +191,11 @@ class RackGroup(models.Model):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.site,
|
||||
self.name,
|
||||
self.slug,
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -198,6 +207,8 @@ class RackRole(models.Model):
|
||||
slug = models.SlugField(unique=True)
|
||||
color = ColorField()
|
||||
|
||||
csv_headers = ['name', 'slug', 'color']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -207,6 +218,13 @@ class RackRole(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.color,
|
||||
)
|
||||
|
||||
|
||||
class RackManager(NaturalOrderByManager):
|
||||
|
||||
@@ -242,7 +260,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
csv_headers = [
|
||||
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
|
||||
'desc_units',
|
||||
'desc_units', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -292,7 +310,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
Device.objects.filter(rack=self).update(site_id=self.site.pk)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.site.name,
|
||||
self.group.name if self.group else None,
|
||||
self.name,
|
||||
@@ -304,7 +322,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.width,
|
||||
self.u_height,
|
||||
self.desc_units,
|
||||
])
|
||||
self.comments,
|
||||
)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
@@ -422,6 +441,7 @@ class RackReservation(models.Model):
|
||||
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
||||
units = ArrayField(models.PositiveSmallIntegerField())
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
|
||||
user = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
description = models.CharField(max_length=100)
|
||||
|
||||
@@ -479,9 +499,7 @@ class Manufacturer(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug',
|
||||
]
|
||||
csv_headers = ['name', 'slug']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -493,10 +511,10 @@ class Manufacturer(models.Model):
|
||||
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -539,7 +557,7 @@ class DeviceType(models.Model, CustomFieldModel):
|
||||
|
||||
csv_headers = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
|
||||
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering',
|
||||
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -562,7 +580,7 @@ class DeviceType(models.Model, CustomFieldModel):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.manufacturer.name,
|
||||
self.model,
|
||||
self.slug,
|
||||
@@ -574,7 +592,8 @@ class DeviceType(models.Model, CustomFieldModel):
|
||||
self.is_network_device,
|
||||
self.get_subdevice_role_display() if self.subdevice_role else None,
|
||||
self.get_interface_ordering_display(),
|
||||
])
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -754,29 +773,53 @@ class DeviceRole(models.Model):
|
||||
help_text="Virtual machines may be assigned to this role"
|
||||
)
|
||||
|
||||
csv_headers = ['name', 'slug', 'color', 'vm_role']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.color,
|
||||
self.vm_role,
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Platform(models.Model):
|
||||
"""
|
||||
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
|
||||
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
|
||||
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
||||
specifying an remote procedure call (RPC) client.
|
||||
specifying a NAPALM driver.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
|
||||
help_text="The name of the NAPALM driver to use when interacting with devices.")
|
||||
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
|
||||
verbose_name='Legacy RPC client')
|
||||
manufacturer = models.ForeignKey(
|
||||
to='Manufacturer',
|
||||
related_name='platforms',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Optionally limit this platform to devices of a certain manufacturer"
|
||||
)
|
||||
napalm_driver = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='NAPALM driver',
|
||||
help_text="The name of the NAPALM driver to use when interacting with devices"
|
||||
)
|
||||
rpc_client = models.CharField(
|
||||
max_length=30,
|
||||
choices=RPC_CLIENT_CHOICES,
|
||||
blank=True,
|
||||
verbose_name="Legacy RPC client"
|
||||
)
|
||||
|
||||
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -787,6 +830,14 @@ class Platform(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.manufacturer.name if self.manufacturer else None,
|
||||
self.napalm_driver,
|
||||
)
|
||||
|
||||
|
||||
class DeviceManager(NaturalOrderByManager):
|
||||
|
||||
@@ -824,7 +875,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
help_text='The lowest-numbered unit occupied by the device'
|
||||
)
|
||||
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
||||
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
|
||||
primary_ip4 = models.OneToOneField(
|
||||
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
@@ -840,6 +891,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
virtual_chassis = models.ForeignKey(
|
||||
to='VirtualChassis',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='members',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
vc_position = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
)
|
||||
vc_priority = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
)
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
images = GenericRelation(ImageAttachment)
|
||||
@@ -848,12 +916,15 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
csv_headers = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'rack_group', 'rack_name', 'position', 'face',
|
||||
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['rack', 'position', 'face']
|
||||
unique_together = [
|
||||
['rack', 'position', 'face'],
|
||||
['virtual_chassis', 'vc_position'],
|
||||
]
|
||||
permissions = (
|
||||
('napalm_read', 'Read-only access to devices via NAPALM'),
|
||||
('napalm_write', 'Read/write access to devices via NAPALM'),
|
||||
@@ -889,6 +960,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
'face': "Must specify rack face when defining rack position.",
|
||||
})
|
||||
|
||||
# Prevent 0U devices from being assigned to a specific position
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
|
||||
})
|
||||
|
||||
if self.rack:
|
||||
|
||||
try:
|
||||
@@ -922,29 +999,36 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Validate primary IPv4 address
|
||||
if self.primary_ip4 and (
|
||||
self.primary_ip4.interface is None or
|
||||
self.primary_ip4.interface.device != self
|
||||
) and (
|
||||
self.primary_ip4.nat_inside.interface is None or
|
||||
self.primary_ip4.nat_inside.interface.device != self
|
||||
):
|
||||
raise ValidationError({
|
||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
|
||||
})
|
||||
# Validate primary IP addresses
|
||||
vc_interfaces = self.vc_interfaces.all()
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip4),
|
||||
})
|
||||
if self.primary_ip6:
|
||||
if self.primary_ip6.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip6),
|
||||
})
|
||||
|
||||
# Validate primary IPv6 address
|
||||
if self.primary_ip6 and (
|
||||
self.primary_ip6.interface is None or
|
||||
self.primary_ip6.interface.device != self
|
||||
) and (
|
||||
self.primary_ip6.nat_inside.interface is None or
|
||||
self.primary_ip6.nat_inside.interface.device != self
|
||||
):
|
||||
raise ValidationError({
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
|
||||
})
|
||||
# Validate manufacturer/platform
|
||||
if self.device_type and self.platform:
|
||||
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||
raise ValidationError({
|
||||
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||
@@ -952,6 +1036,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
|
||||
})
|
||||
|
||||
# Validate virtual chassis assignment
|
||||
if self.virtual_chassis and self.vc_position is None:
|
||||
raise ValidationError({
|
||||
'vc_position': "A device assigned to a virtual chassis must have its position defined."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
is_new = not bool(self.pk)
|
||||
@@ -989,7 +1079,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name or '',
|
||||
self.device_role.name,
|
||||
self.tenant.name if self.tenant else None,
|
||||
@@ -1004,12 +1094,15 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.rack.name if self.rack else None,
|
||||
self.position,
|
||||
self.get_face_display(),
|
||||
])
|
||||
self.comments,
|
||||
)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.virtual_chassis.master.name:
|
||||
return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
|
||||
elif hasattr(self, 'device_type'):
|
||||
return "{}".format(self.device_type)
|
||||
return ""
|
||||
@@ -1034,6 +1127,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_vc_master(self):
|
||||
"""
|
||||
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||
"""
|
||||
return self.virtual_chassis.master if self.virtual_chassis else None
|
||||
|
||||
@property
|
||||
def vc_interfaces(self):
|
||||
"""
|
||||
Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
|
||||
Device belonging to the same VirtualChassis.
|
||||
"""
|
||||
filter = Q(device=self)
|
||||
if self.virtual_chassis and self.virtual_chassis.master == self:
|
||||
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
|
||||
return Interface.objects.filter(filter)
|
||||
|
||||
def get_children(self):
|
||||
"""
|
||||
Return the set of child Devices installed in DeviceBays within this Device.
|
||||
@@ -1041,7 +1151,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
return Device.objects.filter(parent_bay__device=self.pk)
|
||||
|
||||
def get_status_class(self):
|
||||
return DEVICE_STATUS_CLASSES[self.status]
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
def get_rpc_client(self):
|
||||
"""
|
||||
@@ -1076,15 +1186,17 @@ class ConsolePort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
# Used for connections export
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.cs_port.device.identifier if self.cs_port else None,
|
||||
self.cs_port.name if self.cs_port else None,
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.get_connection_status_display(),
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1096,8 +1208,8 @@ class ConsoleServerPortManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
# Pad any trailing digits to effect natural sorting
|
||||
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
|
||||
'name_padded': "CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
|
||||
"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
|
||||
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
|
||||
r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
|
||||
}).order_by('device', 'name_padded')
|
||||
|
||||
|
||||
@@ -1117,6 +1229,9 @@ class ConsoleServerPort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a console server
|
||||
@@ -1124,7 +1239,7 @@ class ConsoleServerPort(models.Model):
|
||||
raise ValidationError("Console server ports must be assigned to devices.")
|
||||
device_type = self.device.device_type
|
||||
if not device_type.is_console_server:
|
||||
raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
|
||||
raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
|
||||
device_type.manufacturer, device_type
|
||||
))
|
||||
|
||||
@@ -1153,15 +1268,17 @@ class PowerPort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
# Used for connections export
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.power_outlet.device.identifier if self.power_outlet else None,
|
||||
self.power_outlet.name if self.power_outlet else None,
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.get_connection_status_display(),
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1173,8 +1290,8 @@ class PowerOutletManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
# Pad any trailing digits to effect natural sorting
|
||||
return super(PowerOutletManager, self).get_queryset().extra(select={
|
||||
'name_padded': "CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
|
||||
"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
|
||||
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
|
||||
r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
|
||||
}).order_by('device', 'name_padded')
|
||||
|
||||
|
||||
@@ -1194,6 +1311,9 @@ class PowerOutlet(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a PDU
|
||||
@@ -1201,7 +1321,7 @@ class PowerOutlet(models.Model):
|
||||
raise ValidationError("Power outlets must be assigned to devices.")
|
||||
device_type = self.device.device_type
|
||||
if not device_type.is_pdu:
|
||||
raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
|
||||
raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
|
||||
device_type.manufacturer, device_type
|
||||
))
|
||||
|
||||
@@ -1249,6 +1369,24 @@ class Interface(models.Model):
|
||||
help_text="This interface is used only for out-of-band management"
|
||||
)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
mode = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_MODE_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
to='ipam.VLAN',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Untagged VLAN',
|
||||
related_name='interfaces_as_untagged'
|
||||
)
|
||||
tagged_vlans = models.ManyToManyField(
|
||||
to='ipam.VLAN',
|
||||
blank=True,
|
||||
verbose_name='Tagged VLANs',
|
||||
related_name='interfaces_as_tagged'
|
||||
)
|
||||
|
||||
objects = InterfaceQuerySet.as_manager()
|
||||
|
||||
@@ -1259,13 +1397,16 @@ class Interface(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.parent.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a network device
|
||||
if self.device is not None:
|
||||
device_type = self.device.device_type
|
||||
if not device_type.is_network_device:
|
||||
raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
|
||||
raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
|
||||
device_type.manufacturer, device_type
|
||||
))
|
||||
|
||||
@@ -1288,8 +1429,8 @@ class Interface(models.Model):
|
||||
"Disconnect the interface or choose a suitable form factor."
|
||||
})
|
||||
|
||||
# An interface's LAG must belong to the same device
|
||||
if self.lag and self.lag.device != self.device:
|
||||
# An interface's LAG must belong to the same device (or VC master)
|
||||
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
|
||||
raise ValidationError({
|
||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||
self.lag.name, self.lag.device.name
|
||||
@@ -1310,6 +1451,25 @@ class Interface(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
if self.mode is None:
|
||||
self.untagged_vlan = None
|
||||
|
||||
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||
if self.pk and self.mode is not IFACE_MODE_TAGGED:
|
||||
self.tagged_vlans.clear()
|
||||
|
||||
return super(Interface, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
@@ -1379,18 +1539,29 @@ class InterfaceConnection(models.Model):
|
||||
raise ValidationError({
|
||||
'interface_b': "Cannot connect an interface to itself."
|
||||
})
|
||||
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'interface_a': '{} is not a connectable interface type.'.format(
|
||||
self.interface_a.get_form_factor_display()
|
||||
)
|
||||
})
|
||||
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'interface_b': '{} is not a connectable interface type.'.format(
|
||||
self.interface_b.get_form_factor_display()
|
||||
)
|
||||
})
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
# Used for connections export
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.interface_a.device.identifier,
|
||||
self.interface_a.name,
|
||||
self.interface_b.device.identifier,
|
||||
self.interface_b.name,
|
||||
self.get_connection_status_display(),
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1414,6 +1585,9 @@ class DeviceBay(models.Model):
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
@@ -1452,9 +1626,67 @@ class InventoryItem(models.Model):
|
||||
discovered = models.BooleanField(default=False, verbose_name='Discovered')
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
|
||||
csv_headers = [
|
||||
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['device__id', 'parent__id', 'name']
|
||||
unique_together = ['device', 'parent', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.name or '{' + self.device.pk + '}',
|
||||
self.name,
|
||||
self.manufacturer.name if self.manufacturer else None,
|
||||
self.part_id,
|
||||
self.serial,
|
||||
self.asset_tag,
|
||||
self.discovered,
|
||||
self.description,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class VirtualChassis(models.Model):
|
||||
"""
|
||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||
"""
|
||||
master = models.OneToOneField(
|
||||
to='Device',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vc_master_for'
|
||||
)
|
||||
domain = models.CharField(
|
||||
max_length=30,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['master']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
|
||||
def __str__(self):
|
||||
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.master.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||
# VirtualChassis.)
|
||||
if self.pk and self.master not in self.members.all():
|
||||
raise ValidationError({
|
||||
'master': "The selected master is not assigned to this virtual chassis."
|
||||
})
|
||||
|
||||
@@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet):
|
||||
}[method]
|
||||
|
||||
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
|
||||
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
|
||||
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)"
|
||||
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)"
|
||||
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)"
|
||||
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)"
|
||||
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
|
||||
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
|
||||
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)"
|
||||
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)"
|
||||
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)"
|
||||
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)"
|
||||
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)"
|
||||
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
|
||||
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)"
|
||||
|
||||
fields = {
|
||||
'_type': RawSQL(TYPE_RE.format(sql_col), []),
|
||||
|
||||
23
netbox/dcim/signals.py
Normal file
23
netbox/dcim/signals.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import Device, VirtualChassis
|
||||
|
||||
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||
"""
|
||||
When a VirtualChassis is created, automatically assign its master device to the VC.
|
||||
"""
|
||||
if created:
|
||||
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=VirtualChassis)
|
||||
def clear_virtualchassis_members(instance, **kwargs):
|
||||
"""
|
||||
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
|
||||
"""
|
||||
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)
|
||||
@@ -3,11 +3,13 @@ from __future__ import unicode_literals
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
|
||||
Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, Region, Site, VirtualChassis,
|
||||
)
|
||||
|
||||
REGION_LINK = """
|
||||
@@ -45,8 +47,13 @@ REGION_ACTIONS = """
|
||||
"""
|
||||
|
||||
RACKGROUP_ACTIONS = """
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackgroup %}
|
||||
<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>
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -64,6 +71,10 @@ RACK_ROLE = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
RACKRESERVATION_ACTIONS = """
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -82,6 +93,22 @@ MANUFACTURER_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
DEVICEROLE_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_ACTIONS = """
|
||||
{% if perms.dcim.change_platform %}
|
||||
<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>
|
||||
@@ -92,7 +119,7 @@ DEVICE_ROLE = """
|
||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_STATUS = """
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
@@ -106,11 +133,21 @@ SUBDEVICE_ROLE_TEMPLATE = """
|
||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
DEVICETYPE_INSTANCES_TEMPLATE = """
|
||||
<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
VIRTUALCHASSIS_ACTIONS = """
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
@@ -139,27 +176,13 @@ class RegionTable(BaseTable):
|
||||
class SiteTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
|
||||
|
||||
|
||||
class SiteDetailTable(SiteTable):
|
||||
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')
|
||||
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')
|
||||
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
|
||||
vm_count = tables.Column(accessor=Accessor('count_vms'), orderable=False, verbose_name='VMs')
|
||||
|
||||
class Meta(SiteTable.Meta):
|
||||
fields = (
|
||||
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
|
||||
'vlan_count', 'circuit_count', 'vm_count',
|
||||
)
|
||||
fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -168,12 +191,21 @@ class SiteDetailTable(SiteTable):
|
||||
|
||||
class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
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')
|
||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')],
|
||||
verbose_name='Site'
|
||||
)
|
||||
rack_count = tables.Column(
|
||||
verbose_name='Racks'
|
||||
)
|
||||
slug = tables.Column()
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKGROUP_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
@@ -207,7 +239,7 @@ class RackTable(BaseTable):
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
role = tables.TemplateColumn(RACK_ROLE)
|
||||
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
||||
|
||||
@@ -217,12 +249,16 @@ class RackTable(BaseTable):
|
||||
|
||||
|
||||
class RackDetailTable(RackTable):
|
||||
devices = tables.Column(accessor=Accessor('device_count'))
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=RACK_DEVICE_COUNT,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
|
||||
class Meta(RackTable.Meta):
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
|
||||
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
'get_utilization',
|
||||
)
|
||||
|
||||
|
||||
@@ -231,7 +267,7 @@ class RackImportTable(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')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
u_height = tables.Column(verbose_name='Height (U)')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -245,6 +281,7 @@ class RackImportTable(BaseTable):
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -253,7 +290,7 @@ class RackReservationTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -264,13 +301,14 @@ class ManufacturerTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||
platform_count = tables.Column(verbose_name='Platforms')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
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', 'actions')
|
||||
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -279,13 +317,23 @@ class ManufacturerTable(BaseTable):
|
||||
|
||||
class DeviceTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
model = tables.LinkColumn(
|
||||
viewname='dcim:devicetype',
|
||||
args=[Accessor('pk')],
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
||||
instance_count = tables.Column(verbose_name='Instances')
|
||||
subdevice_role = tables.TemplateColumn(
|
||||
template_code=SUBDEVICE_ROLE_TEMPLATE,
|
||||
verbose_name='Subdevice Role'
|
||||
)
|
||||
instance_count = tables.TemplateColumn(
|
||||
template_code=DEVICETYPE_INSTANCES_TEMPLATE,
|
||||
verbose_name='Instances'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
@@ -360,13 +408,25 @@ class DeviceBayTemplateTable(BaseTable):
|
||||
|
||||
class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
vm_count = tables.Column(verbose_name='VMs')
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
@@ -379,15 +439,27 @@ class DeviceRoleTable(BaseTable):
|
||||
|
||||
class PlatformTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
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', 'napalm_driver', 'actions')
|
||||
fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -397,8 +469,8 @@ class PlatformTable(BaseTable):
|
||||
class DeviceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||
@@ -424,8 +496,8 @@ class DeviceDetailTable(DeviceTable):
|
||||
|
||||
class DeviceImportTable(BaseTable):
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
position = tables.Column(verbose_name='Position')
|
||||
@@ -521,5 +593,38 @@ class InterfaceConnectionTable(BaseTable):
|
||||
interface_b = tables.Column(verbose_name='Interface B')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
model = InterfaceConnection
|
||||
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
|
||||
|
||||
|
||||
#
|
||||
# InventoryItems
|
||||
#
|
||||
|
||||
class InventoryItemTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device = tables.LinkColumn('dcim:device_inventory', args=[Accessor('device.pk')])
|
||||
manufacturer = tables.Column(accessor=Accessor('manufacturer.name'), verbose_name='Manufacturer')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
master = tables.LinkColumn()
|
||||
member_count = tables.Column(verbose_name='Members')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VIRTUALCHASSIS_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'master', 'domain', 'member_count', 'actions')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 41,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||
self.assertTrue(test.save())
|
||||
@@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 1,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(test.is_valid())
|
||||
|
||||
@@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
@@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_REAR,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
@@ -140,8 +140,8 @@ urlpatterns = [
|
||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), 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+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
|
||||
@@ -150,17 +150,18 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
|
||||
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+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
|
||||
|
||||
# Power ports
|
||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), 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+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
|
||||
@@ -169,10 +170,11 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
|
||||
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+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||
@@ -180,10 +182,12 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
|
||||
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'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||
|
||||
# Device bays
|
||||
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||
@@ -191,13 +195,18 @@ urlpatterns = [
|
||||
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.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
|
||||
url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
|
||||
|
||||
# Inventory items
|
||||
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
|
||||
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
|
||||
url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
|
||||
url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
|
||||
url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
|
||||
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
|
||||
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
|
||||
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
|
||||
|
||||
# Console/power/interface connections
|
||||
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
||||
@@ -207,4 +216,12 @@ urlpatterns = [
|
||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||
|
||||
# Virtual chassis
|
||||
url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
|
||||
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
|
||||
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
|
||||
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||
@admin.register(CustomField)
|
||||
class CustomFieldAdmin(admin.ModelAdmin):
|
||||
inlines = [CustomFieldChoiceAdmin]
|
||||
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
|
||||
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
|
||||
form = CustomFieldForm
|
||||
|
||||
def models(self, obj):
|
||||
|
||||
@@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
|
||||
from extras import filters
|
||||
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||
from extras.reports import get_report, get_reports
|
||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
||||
# Graphs
|
||||
#
|
||||
|
||||
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class GraphViewSet(ModelViewSet):
|
||||
queryset = Graph.objects.all()
|
||||
serializer_class = serializers.GraphSerializer
|
||||
write_serializer_class = serializers.WritableGraphSerializer
|
||||
@@ -75,7 +75,7 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Export templates
|
||||
#
|
||||
|
||||
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ExportTemplateViewSet(ModelViewSet):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
serializer_class = serializers.ExportTemplateSerializer
|
||||
filter_class = filters.ExportTemplateFilter
|
||||
@@ -85,7 +85,7 @@ class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class TopologyMapViewSet(ModelViewSet):
|
||||
queryset = TopologyMap.objects.select_related('site')
|
||||
serializer_class = serializers.TopologyMapSerializer
|
||||
write_serializer_class = serializers.WritableTopologyMapSerializer
|
||||
@@ -99,7 +99,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
|
||||
try:
|
||||
data = tmap.render(img_format=img_format)
|
||||
except:
|
||||
except Exception:
|
||||
return HttpResponse(
|
||||
"There was an error generating the requested graph. Ensure that the GraphViz executables have been "
|
||||
"installed correctly."
|
||||
@@ -115,7 +115,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ImageAttachmentViewSet(ModelViewSet):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||
|
||||
@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
|
||||
(CF_TYPE_SELECT, 'Selection'),
|
||||
)
|
||||
|
||||
# Custom field filter logic choices
|
||||
CF_FILTER_DISABLED = 0
|
||||
CF_FILTER_LOOSE = 1
|
||||
CF_FILTER_EXACT = 2
|
||||
CF_FILTER_CHOICES = (
|
||||
(CF_FILTER_DISABLED, 'Disabled'),
|
||||
(CF_FILTER_LOOSE, 'Loose'),
|
||||
(CF_FILTER_EXACT, 'Exact'),
|
||||
)
|
||||
|
||||
# Graph types
|
||||
GRAPH_TYPE_INTERFACE = 100
|
||||
GRAPH_TYPE_PROVIDER = 200
|
||||
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
]
|
||||
|
||||
# Topology map types
|
||||
TOPOLOGYMAP_TYPE_NETWORK = 1
|
||||
TOPOLOGYMAP_TYPE_CONSOLE = 2
|
||||
TOPOLOGYMAP_TYPE_POWER = 3
|
||||
TOPOLOGYMAP_TYPE_CHOICES = (
|
||||
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
|
||||
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
|
||||
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
|
||||
)
|
||||
|
||||
# User action types
|
||||
ACTION_CREATE = 1
|
||||
ACTION_IMPORT = 2
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from dcim.models import Site
|
||||
from .constants import CF_TYPE_SELECT
|
||||
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
|
||||
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ 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
|
||||
def __init__(self, custom_field, *args, **kwargs):
|
||||
self.cf_type = custom_field.type
|
||||
self.filter_logic = custom_field.filter_logic
|
||||
super(CustomFieldFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
@@ -41,10 +42,19 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
|
||||
return queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value__icontains=value,
|
||||
)
|
||||
# Apply the assigned filter logic (exact or loose)
|
||||
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value=value
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value__icontains=value
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
@@ -56,9 +66,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
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)
|
||||
custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
|
||||
for cf in custom_fields:
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
|
||||
|
||||
|
||||
class GraphFilter(django_filters.FilterSet):
|
||||
|
||||
@@ -4,9 +4,10 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
|
||||
from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
|
||||
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
|
||||
from .models import CustomField, CustomFieldValue, ImageAttachment
|
||||
|
||||
|
||||
@@ -15,17 +16,17 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
Retrieve all CustomFields applicable to the given ContentType
|
||||
"""
|
||||
field_dict = OrderedDict()
|
||||
kwargs = {'obj_type': content_type}
|
||||
custom_fields = CustomField.objects.filter(obj_type=content_type)
|
||||
if filterable_only:
|
||||
kwargs['is_filterable'] = True
|
||||
custom_fields = CustomField.objects.filter(**kwargs)
|
||||
custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
|
||||
|
||||
for cf in custom_fields:
|
||||
field_name = 'cf_{}'.format(str(cf.name))
|
||||
initial = cf.default if not bulk_edit else None
|
||||
|
||||
# Integer
|
||||
if cf.type == CF_TYPE_INTEGER:
|
||||
field = forms.IntegerField(required=cf.required, initial=cf.default)
|
||||
field = forms.IntegerField(required=cf.required, initial=initial)
|
||||
|
||||
# Boolean
|
||||
elif cf.type == CF_TYPE_BOOLEAN:
|
||||
@@ -34,33 +35,41 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
(1, 'True'),
|
||||
(0, 'False'),
|
||||
)
|
||||
if cf.default.lower() in ['true', 'yes', '1']:
|
||||
if initial is not None and initial.lower() in ['true', 'yes', '1']:
|
||||
initial = 1
|
||||
elif cf.default.lower() in ['false', 'no', '0']:
|
||||
elif initial is not None and initial.lower() in ['false', 'no', '0']:
|
||||
initial = 0
|
||||
else:
|
||||
initial = None
|
||||
field = forms.NullBooleanField(required=cf.required, initial=initial,
|
||||
widget=forms.Select(choices=choices))
|
||||
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, help_text="Date format: YYYY-MM-DD")
|
||||
field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
|
||||
|
||||
# Select
|
||||
elif cf.type == CF_TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
||||
if not cf.required or bulk_edit or filterable_only:
|
||||
choices = [(None, '---------')] + choices
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
|
||||
# Check for a default choice
|
||||
default_choice = None
|
||||
if initial:
|
||||
try:
|
||||
default_choice = cf.choices.get(value=initial).pk
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
|
||||
|
||||
# URL
|
||||
elif cf.type == CF_TYPE_URL:
|
||||
field = LaxURLField(required=cf.required, initial=cf.default)
|
||||
field = LaxURLField(required=cf.required, initial=initial)
|
||||
|
||||
# Text
|
||||
else:
|
||||
field = forms.CharField(max_length=255, required=cf.required, initial=cf.default)
|
||||
field = forms.CharField(max_length=255, required=cf.required, initial=initial)
|
||||
|
||||
field.model = cf
|
||||
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db import transaction
|
||||
from ncclient.transport.errors import AuthenticationError
|
||||
from paramiko import AuthenticationException
|
||||
|
||||
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
|
||||
from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -41,7 +41,7 @@ class Command(BaseCommand):
|
||||
self.password = getpass("Password: ")
|
||||
|
||||
# Attempt to inventory only active devices
|
||||
device_list = Device.objects.filter(status=STATUS_ACTIVE)
|
||||
device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
|
||||
|
||||
# --site: Include only devices belonging to specified site(s)
|
||||
if options['site']:
|
||||
|
||||
@@ -4,14 +4,6 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from extras.models import TopologyMap
|
||||
|
||||
|
||||
def commas_to_semicolons(apps, schema_editor):
|
||||
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
|
||||
tm.device_patterns = tm.device_patterns.replace(',', ';')
|
||||
tm.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
|
||||
name='device_patterns',
|
||||
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
|
||||
),
|
||||
migrations.RunPython(commas_to_semicolons),
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='default',
|
||||
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
|
||||
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
|
||||
@@ -19,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
row = cursor.fetchone()
|
||||
pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||
|
||||
|
||||
20
netbox/extras/migrations/0009_topologymap_type.py
Normal file
20
netbox/extras/migrations/0009_topologymap_type.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-15 16:28
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0008_reports'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='topologymap',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
|
||||
),
|
||||
]
|
||||
51
netbox/extras/migrations/0010_customfield_filter_logic.py
Normal file
51
netbox/extras/migrations/0010_customfield_filter_logic.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-21 19:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
|
||||
|
||||
|
||||
def is_filterable_to_filter_logic(apps, schema_editor):
|
||||
CustomField = apps.get_model('extras', 'CustomField')
|
||||
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
|
||||
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
|
||||
# Select fields match on primary key only
|
||||
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
|
||||
|
||||
|
||||
def filter_logic_to_is_filterable(apps, schema_editor):
|
||||
CustomField = apps.get_model('extras', 'CustomField')
|
||||
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
|
||||
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0009_topologymap_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='filter_logic',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='required',
|
||||
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='weight',
|
||||
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
|
||||
),
|
||||
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
|
||||
migrations.RemoveField(
|
||||
model_name='customfield',
|
||||
name='is_filterable',
|
||||
),
|
||||
]
|
||||
@@ -16,6 +16,7 @@ from django.template import Template, Context
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||
from utilities.utils import foreground_color
|
||||
from .constants import *
|
||||
|
||||
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
|
||||
|
||||
@python_2_unicode_compatible
|
||||
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")
|
||||
obj_type = models.ManyToManyField(
|
||||
to=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='If true, this field is required when creating new objects or editing an existing object.'
|
||||
)
|
||||
filter_logic = models.PositiveSmallIntegerField(
|
||||
choices=CF_FILTER_CHOICES,
|
||||
default=CF_FILTER_LOOSE,
|
||||
help_text="Loose matches any instance of a given string; exact matches the entire field."
|
||||
)
|
||||
default = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text='Default value for the field. Use "true" or "false" for booleans.'
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
@@ -100,7 +127,7 @@ class CustomField(models.Model):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value is '':
|
||||
if serialized_value == '':
|
||||
return None
|
||||
if self.type == CF_TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
@@ -223,19 +250,25 @@ class ExportTemplate(models.Model):
|
||||
def __str__(self):
|
||||
return '{}: {}'.format(self.content_type, self.name)
|
||||
|
||||
def to_response(self, context_dict, filename):
|
||||
def render_to_response(self, queryset):
|
||||
"""
|
||||
Render the template to an HTTP response, delivered as a named file attachment
|
||||
"""
|
||||
template = Template(self.template_code)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
output = template.render(Context(context_dict))
|
||||
output = template.render(Context({'queryset': queryset}))
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
output = output.replace('\r\n', '\n')
|
||||
|
||||
# Build the response
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
if self.file_extension:
|
||||
filename += '.{}'.format(self.file_extension)
|
||||
filename = 'netbox_{}{}'.format(
|
||||
queryset.model._meta.verbose_name_plural,
|
||||
'.{}'.format(self.file_extension) if self.file_extension else ''
|
||||
)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -247,7 +280,17 @@ class ExportTemplate(models.Model):
|
||||
class TopologyMap(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=TOPOLOGYMAP_TYPE_CHOICES,
|
||||
default=TOPOLOGYMAP_TYPE_NETWORK
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
related_name='topology_maps',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
device_patterns = models.TextField(
|
||||
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
|
||||
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
|
||||
@@ -269,22 +312,26 @@ class TopologyMap(models.Model):
|
||||
|
||||
def render(self, img_format='png'):
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
|
||||
from dcim.models import Device
|
||||
|
||||
# Construct the graph
|
||||
graph = graphviz.Graph()
|
||||
graph.graph_attr['ranksep'] = '1'
|
||||
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
|
||||
G = graphviz.Graph
|
||||
else:
|
||||
G = graphviz.Digraph
|
||||
self.graph = G()
|
||||
self.graph.graph_attr['ranksep'] = '1'
|
||||
seen = set()
|
||||
for i, device_set in enumerate(self.device_sets):
|
||||
|
||||
subgraph = graphviz.Graph(name='sg{}'.format(i))
|
||||
subgraph = G(name='sg{}'.format(i))
|
||||
subgraph.graph_attr['rank'] = 'same'
|
||||
subgraph.graph_attr['directed'] = 'true'
|
||||
|
||||
# Add a pseudonode for each device_set to enforce hierarchical layout
|
||||
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
||||
if i:
|
||||
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
@@ -302,31 +349,64 @@ class TopologyMap(models.Model):
|
||||
for j in range(0, len(devices) - 1):
|
||||
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
||||
|
||||
graph.subgraph(subgraph)
|
||||
self.graph.subgraph(subgraph)
|
||||
|
||||
# Compile list of all devices
|
||||
device_superset = Q()
|
||||
for device_set in self.device_sets:
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
device_superset = device_superset | Q(name__regex=query)
|
||||
devices = Device.objects.filter(*(device_superset,))
|
||||
|
||||
# Draw edges depending on graph type
|
||||
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
|
||||
self.add_network_connections(devices)
|
||||
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
|
||||
self.add_console_connections(devices)
|
||||
elif self.type == TOPOLOGYMAP_TYPE_POWER:
|
||||
self.add_power_connections(devices)
|
||||
|
||||
return self.graph.pipe(format=img_format)
|
||||
|
||||
def add_network_connections(self, devices):
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from dcim.models import InterfaceConnection
|
||||
|
||||
# Add all interface connections to the graph
|
||||
devices = Device.objects.filter(*(device_superset,))
|
||||
connections = InterfaceConnection.objects.filter(
|
||||
interface_a__device__in=devices, interface_b__device__in=devices
|
||||
)
|
||||
for c in connections:
|
||||
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
|
||||
self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
|
||||
|
||||
# Add all circuits to the graph
|
||||
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
|
||||
peer_termination = termination.get_peer_termination()
|
||||
if (peer_termination is not None and peer_termination.interface is not None and
|
||||
peer_termination.interface.device in devices):
|
||||
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
|
||||
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
|
||||
|
||||
return graph.pipe(format=img_format)
|
||||
def add_console_connections(self, devices):
|
||||
|
||||
from dcim.models import ConsolePort
|
||||
|
||||
# Add all console connections to the graph
|
||||
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
|
||||
for cp in console_ports:
|
||||
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
|
||||
|
||||
def add_power_connections(self, devices):
|
||||
|
||||
from dcim.models import PowerPort
|
||||
|
||||
# Add all power connections to the graph
|
||||
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
|
||||
for pp in power_ports:
|
||||
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -163,8 +163,8 @@ class IOSSSH(SSHClient):
|
||||
|
||||
sh_ver = self._send('show version').split('\r\n')
|
||||
return {
|
||||
'serial': parse(sh_ver, 'Processor board ID ([^\s]+)'),
|
||||
'description': parse(sh_ver, 'cisco ([^\s]+)')
|
||||
'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'),
|
||||
'description': parse(sh_ver, r'cisco ([^\s]+)')
|
||||
}
|
||||
|
||||
def items(chassis_serial=None):
|
||||
@@ -172,9 +172,9 @@ class IOSSSH(SSHClient):
|
||||
for i in cmd:
|
||||
i_fmt = i.replace('\r\n', ' ')
|
||||
try:
|
||||
m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
|
||||
m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
|
||||
m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1)
|
||||
m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1)
|
||||
m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1)
|
||||
m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1)
|
||||
# Omit built-in items and those with no PID
|
||||
if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
|
||||
yield {
|
||||
@@ -208,7 +208,7 @@ class OpengearSSH(SSHClient):
|
||||
try:
|
||||
stdin, stdout, stderr = self.ssh.exec_command("showserial")
|
||||
serial = stdout.readlines()[0].strip()
|
||||
except:
|
||||
except Exception:
|
||||
raise RuntimeError("Failed to glean chassis serial from device.")
|
||||
# Older models don't provide serial info
|
||||
if serial == "No serial number information available":
|
||||
@@ -217,7 +217,7 @@ class OpengearSSH(SSHClient):
|
||||
try:
|
||||
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
|
||||
description = stdout.readlines()[0].split(' ', 1)[1].strip()
|
||||
except:
|
||||
except Exception:
|
||||
raise RuntimeError("Failed to glean chassis description from device.")
|
||||
|
||||
return {
|
||||
|
||||
@@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 4)
|
||||
@@ -63,6 +63,35 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(graph4.name, data['name'])
|
||||
self.assertEqual(graph4.source, data['source'])
|
||||
|
||||
def test_create_graph_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 4',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||
},
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 5',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
|
||||
},
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 6',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_graph(self):
|
||||
|
||||
data = {
|
||||
@@ -72,7 +101,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Graph.objects.count(), 3)
|
||||
@@ -135,7 +164,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||
@@ -144,6 +173,35 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(exporttemplate4.name, data['name'])
|
||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||
|
||||
def test_create_exporttemplate_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 5',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 6',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
@@ -153,7 +211,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 3)
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
||||
from dcim.models import Interface
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.constants import (
|
||||
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
|
||||
@@ -25,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class NestedVRFSerializer(serializers.ModelSerializer):
|
||||
@@ -40,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -90,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||
@@ -105,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
|
||||
#
|
||||
@@ -165,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer):
|
||||
model = VLAN
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -181,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
@@ -215,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -233,23 +245,47 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class AvailablePrefixSerializer(serializers.Serializer):
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
|
||||
else:
|
||||
vrf = None
|
||||
return OrderedDict([
|
||||
('family', instance.version),
|
||||
('prefix', str(instance)),
|
||||
('vrf', vrf),
|
||||
])
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressInterfaceSerializer(InterfaceSerializer):
|
||||
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
|
||||
device = NestedDeviceSerializer()
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
|
||||
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
|
||||
'id', 'url', 'device', 'virtual_machine', 'name',
|
||||
]
|
||||
|
||||
def get_url(self, obj):
|
||||
"""
|
||||
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
|
||||
if the parent is a VirtualMachine.
|
||||
"""
|
||||
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
|
||||
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
|
||||
|
||||
|
||||
class IPAddressSerializer(CustomFieldModelSerializer):
|
||||
vrf = NestedVRFSerializer()
|
||||
@@ -262,7 +298,7 @@ class IPAddressSerializer(CustomFieldModelSerializer):
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'custom_fields',
|
||||
'nat_outside', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -284,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -314,7 +350,10 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||
@@ -322,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
@@ -4,14 +4,13 @@ from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from ipam import filters
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -33,7 +32,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
write_serializer_class = serializers.WritableVRFSerializer
|
||||
@@ -54,7 +53,7 @@ class RIRViewSet(ModelViewSet):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class AggregateViewSet(CustomFieldModelViewSet):
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
write_serializer_class = serializers.WritableAggregateSerializer
|
||||
@@ -75,12 +74,96 @@ class RoleViewSet(ModelViewSet):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
write_serializer_class = serializers.WritablePrefixSerializer
|
||||
filter_class = filters.PrefixFilter
|
||||
|
||||
@detail_route(url_path='available-prefixes', methods=['get', 'post'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for returning available child prefixes within a parent.
|
||||
"""
|
||||
prefix = get_object_or_404(Prefix, pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('ipam.add_prefix'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Allocate prefixes to the requested objects based on availability within the parent
|
||||
for i, requested_prefix in enumerate(requested_prefixes):
|
||||
|
||||
# Validate requested prefix size
|
||||
error_msg = None
|
||||
if 'prefix_length' not in requested_prefix:
|
||||
error_msg = "Item {}: prefix_length field missing".format(i)
|
||||
elif not isinstance(requested_prefix['prefix_length'], int):
|
||||
error_msg = "Item {}: Invalid prefix length ({})".format(
|
||||
i, requested_prefix['prefix_length']
|
||||
)
|
||||
elif prefix.family == 4 and requested_prefix['prefix_length'] > 32:
|
||||
error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format(
|
||||
i, requested_prefix['prefix_length']
|
||||
)
|
||||
elif prefix.family == 6 and requested_prefix['prefix_length'] > 128:
|
||||
error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format(
|
||||
i, requested_prefix['prefix_length']
|
||||
)
|
||||
if error_msg:
|
||||
return Response(
|
||||
{
|
||||
"detail": error_msg
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Find the first available prefix equal to or larger than the requested size
|
||||
for available_prefix in available_prefixes.iter_cidrs():
|
||||
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
||||
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
||||
requested_prefix['prefix'] = allocated_prefix
|
||||
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
break
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Remove the allocated prefix from the list of available prefixes
|
||||
available_prefixes.remove(allocated_prefix)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
|
||||
else:
|
||||
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
else:
|
||||
|
||||
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
||||
'request': request,
|
||||
'vrf': prefix.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@detail_route(url_path='available-ips', methods=['get', 'post'])
|
||||
def available_ips(self, request, pk=None):
|
||||
"""
|
||||
@@ -97,28 +180,41 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
if not request.user.has_perm('ipam.add_ipaddress'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Find the first available IP address in the prefix
|
||||
try:
|
||||
ipaddress = list(prefix.get_available_ips())[0]
|
||||
except IndexError:
|
||||
# Normalize to a list of objects
|
||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Determine if the requested number of IPs is available
|
||||
available_ips = prefix.get_available_ips()
|
||||
if available_ips.size < len(requested_ips):
|
||||
return Response(
|
||||
{
|
||||
"detail": "There are no available IPs within this prefix ({})".format(prefix)
|
||||
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
|
||||
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create the new IP address
|
||||
data = request.data.copy()
|
||||
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
|
||||
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
serializer = serializers.WritableIPAddressSerializer(data=data)
|
||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
|
||||
available_ips = iter(available_ips)
|
||||
prefix_length = prefix.prefix.prefixlen
|
||||
for requested_ip in requested_ips:
|
||||
requested_ip['address'] = '{}/{}'.format(next(available_ips), prefix_length)
|
||||
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
|
||||
else:
|
||||
serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Determine the maximum amount of IPs to return
|
||||
# Determine the maximum number of IPs to return
|
||||
else:
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
||||
@@ -146,11 +242,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
|
||||
).prefetch_related(
|
||||
'interface__device', 'interface__virtual_machine'
|
||||
'nat_outside'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||
@@ -161,7 +257,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
write_serializer_class = serializers.WritableVLANGroupSerializer
|
||||
@@ -172,7 +268,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
write_serializer_class = serializers.WritableVLANSerializer
|
||||
@@ -183,7 +279,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
queryset = Service.objects.select_related('device')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
write_serializer_class = serializers.WritableServiceSerializer
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from netaddr import IPNetwork
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
from .formfields import IPFormField
|
||||
from . import lookups
|
||||
@@ -26,7 +26,9 @@ class BaseIPField(models.Field):
|
||||
return value
|
||||
try:
|
||||
return IPNetwork(value)
|
||||
except ValueError as e:
|
||||
except AddrFormatError as e:
|
||||
raise ValidationError("Invalid IP address format: {}".format(value))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
def get_prep_value(self, value):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
import netaddr
|
||||
from netaddr.core import AddrFormatError
|
||||
@@ -99,11 +100,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
# TODO: Deprecate in v2.3.0
|
||||
parent = django_filters.CharFilter(
|
||||
method='search_within_include',
|
||||
label='Parent prefix (deprecated)',
|
||||
)
|
||||
within = django_filters.CharFilter(
|
||||
method='search_within',
|
||||
label='Within prefix',
|
||||
@@ -238,6 +234,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
address = django_filters.CharFilter(
|
||||
method='filter_address',
|
||||
label='Address',
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
method='filter_mask_length',
|
||||
label='Mask length',
|
||||
@@ -262,16 +262,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_device',
|
||||
name='name',
|
||||
label='Device',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
device_id = django_filters.NumberFilter(
|
||||
method='filter_device',
|
||||
name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__virtual_machine',
|
||||
@@ -319,11 +318,30 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
try:
|
||||
# Match address and subnet mask
|
||||
if '/' in value:
|
||||
return queryset.filter(address=value)
|
||||
return queryset.filter(address__net_host=value)
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def filter_mask_length(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||
return queryset.filter(interface_id__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class VLANGroupFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
|
||||
@@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
|
||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
||||
add_blank_choice,
|
||||
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
|
||||
SlugField, add_blank_choice,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
||||
@@ -57,7 +57,7 @@ class VRFCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
fields = VRF.csv_headers
|
||||
help_texts = {
|
||||
'name': 'VRF name',
|
||||
}
|
||||
@@ -78,8 +78,11 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VRF
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
|
||||
null_option=(0, None))
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('vrfs')),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -99,7 +102,7 @@ class RIRCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['name', 'slug', 'is_private']
|
||||
fields = RIR.csv_headers
|
||||
help_texts = {
|
||||
'name': 'RIR name',
|
||||
}
|
||||
@@ -141,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['prefix', 'rir', 'date_added', 'description']
|
||||
fields = Aggregate.csv_headers
|
||||
|
||||
|
||||
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
@@ -182,7 +185,7 @@ class RoleCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['name', 'slug']
|
||||
fields = Role.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Role name',
|
||||
}
|
||||
@@ -296,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = [
|
||||
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
|
||||
]
|
||||
fields = Prefix.csv_headers
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -349,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -368,23 +362,28 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
|
||||
to_field_name='rd',
|
||||
label='VRF',
|
||||
null_option=(0, 'Global')
|
||||
null_label='-- Global --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=PREFIX_STATUS_CHOICES,
|
||||
annotate=Prefix.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
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')
|
||||
null_label='-- None --'
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
|
||||
|
||||
@@ -509,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
|
||||
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
||||
|
||||
# Assign this IPAddress as the primary for the associated Device.
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4:
|
||||
@@ -517,19 +516,14 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
else:
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
# Clear assignment as primary for device if set.
|
||||
else:
|
||||
try:
|
||||
if ipaddress.address.version == 4:
|
||||
device = ipaddress.primary_ip4_for
|
||||
device.primary_ip4 = None
|
||||
else:
|
||||
device = ipaddress.primary_ip6_for
|
||||
device.primary_ip6 = None
|
||||
device.save()
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
elif self.cleaned_data['interface']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
|
||||
parent.primary_ip4 = None
|
||||
parent.save()
|
||||
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
|
||||
parent.primary_ip6 = None
|
||||
parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
@@ -606,10 +600,7 @@ class IPAddressCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
|
||||
'description',
|
||||
]
|
||||
fields = IPAddress.csv_headers
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -693,20 +684,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
address = forms.CharField(label='IP Address')
|
||||
|
||||
|
||||
def ipaddress_status_choices():
|
||||
status_counts = {}
|
||||
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
|
||||
|
||||
|
||||
def ipaddress_role_choices():
|
||||
role_counts = {}
|
||||
for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
|
||||
role_counts[role['role']] = role['count']
|
||||
return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = IPAddress
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -719,15 +696,25 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
|
||||
to_field_name='rd',
|
||||
label='VRF',
|
||||
null_option=(0, 'Global')
|
||||
null_label='-- Global --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_STATUS_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
role = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_ROLE_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='role',
|
||||
required=False
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
||||
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
|
||||
|
||||
|
||||
#
|
||||
@@ -756,7 +743,7 @@ class VLANGroupCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['site', 'name', 'slug']
|
||||
fields = VLANGroup.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of VLAN group',
|
||||
}
|
||||
@@ -766,7 +753,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'Global')
|
||||
null_label='-- Global --'
|
||||
)
|
||||
|
||||
|
||||
@@ -846,7 +833,7 @@ class VLANCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
fields = VLAN.csv_headers
|
||||
help_texts = {
|
||||
'vid': 'Numeric VLAN ID (1-4095)',
|
||||
'name': 'VLAN name',
|
||||
@@ -883,36 +870,34 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
status_counts = {}
|
||||
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('vlans')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'Global')
|
||||
null_label='-- Global --'
|
||||
)
|
||||
group_id = FilterChoiceField(
|
||||
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
|
||||
label='VLAN group',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None')
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=VLAN_STATUS_CHOICES,
|
||||
annotate=VLAN.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
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')
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
@@ -936,8 +921,9 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||
if self.instance.device:
|
||||
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__device=self.instance.device
|
||||
interface_id__in=vc_interface_ids
|
||||
)
|
||||
elif self.instance.virtual_machine:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
|
||||
19
netbox/ipam/migrations/0021_vrf_ordering.py
Normal file
19
netbox/ipam/migrations/0021_vrf_ordering.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-07 18:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0020_ipaddress_add_role_carp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='vrf',
|
||||
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
@@ -14,7 +15,6 @@ from dcim.models import Interface
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.utils import csv_format
|
||||
from .constants import *
|
||||
from .fields import IPNetworkField, IPAddressField
|
||||
from .querysets import PrefixQuerySet
|
||||
@@ -38,7 +38,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
|
||||
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
ordering = ['name', 'rd']
|
||||
verbose_name = 'VRF'
|
||||
verbose_name_plural = 'VRFs'
|
||||
|
||||
@@ -49,13 +49,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
|
||||
return reverse('ipam:vrf', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.name,
|
||||
self.rd,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.enforce_unique,
|
||||
self.description,
|
||||
])
|
||||
)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
@@ -75,6 +75,8 @@ class RIR(models.Model):
|
||||
is_private = models.BooleanField(default=False, verbose_name='Private',
|
||||
help_text='IP space managed by this RIR is considered private')
|
||||
|
||||
csv_headers = ['name', 'slug', 'is_private']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'RIR'
|
||||
@@ -86,6 +88,13 @@ class RIR(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.is_private,
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
||||
@@ -147,12 +156,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
||||
super(Aggregate, self).save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.prefix,
|
||||
self.rir.name,
|
||||
self.date_added.isoformat() if self.date_added else None,
|
||||
self.date_added,
|
||||
self.description,
|
||||
])
|
||||
)
|
||||
|
||||
def get_utilization(self):
|
||||
"""
|
||||
@@ -173,19 +182,20 @@ class Role(models.Model):
|
||||
slug = models.SlugField(unique=True)
|
||||
weight = models.PositiveSmallIntegerField(default=1000)
|
||||
|
||||
csv_headers = ['name', 'slug', 'weight']
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def count_prefixes(self):
|
||||
return self.prefixes.count()
|
||||
|
||||
@property
|
||||
def count_vlans(self):
|
||||
return self.vlans.count()
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.weight,
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -262,7 +272,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
super(Prefix, self).save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.prefix,
|
||||
self.vrf.rd if self.vrf else None,
|
||||
self.tenant.name if self.tenant else None,
|
||||
@@ -273,7 +283,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.role.name if self.role else None,
|
||||
self.is_pool,
|
||||
self.description,
|
||||
])
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
@@ -281,11 +291,35 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
def get_duplicates(self):
|
||||
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
|
||||
|
||||
def get_child_prefixes(self):
|
||||
"""
|
||||
Return all Prefixes within this Prefix and VRF. If this Prefix is a container in the global table, return child
|
||||
Prefixes belonging to any VRF.
|
||||
"""
|
||||
if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
|
||||
return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
|
||||
else:
|
||||
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
|
||||
|
||||
def get_child_ips(self):
|
||||
"""
|
||||
Return all IPAddresses within this Prefix.
|
||||
Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
|
||||
child IPAddresses belonging to any VRF.
|
||||
"""
|
||||
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
|
||||
if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
|
||||
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
|
||||
else:
|
||||
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
|
||||
|
||||
def get_available_prefixes(self):
|
||||
"""
|
||||
Return all available Prefixes within this prefix as an IPSet.
|
||||
"""
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||
available_prefixes = prefix - child_prefixes
|
||||
|
||||
return available_prefixes
|
||||
|
||||
def get_available_ips(self):
|
||||
"""
|
||||
@@ -304,15 +338,23 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
return available_ips
|
||||
|
||||
def get_first_available_prefix(self):
|
||||
"""
|
||||
Return the first available child prefix within the prefix (or None).
|
||||
"""
|
||||
available_prefixes = self.get_available_prefixes()
|
||||
if not available_prefixes:
|
||||
return None
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
def get_first_available_ip(self):
|
||||
"""
|
||||
Return the first available IP within the prefix (or None).
|
||||
"""
|
||||
available_ips = self.get_available_ips()
|
||||
if available_ips:
|
||||
return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
|
||||
else:
|
||||
if not available_ips:
|
||||
return None
|
||||
return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
|
||||
|
||||
def get_utilization(self):
|
||||
"""
|
||||
@@ -324,23 +366,13 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
else:
|
||||
child_count = self.get_child_ips().count()
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||
prefix_size = self.prefix.size
|
||||
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
return int(float(child_count) / prefix_size * 100)
|
||||
|
||||
@property
|
||||
def new_subnet(self):
|
||||
if self.family == 4:
|
||||
if self.prefix.prefixlen <= 30:
|
||||
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
|
||||
return None
|
||||
if self.family == 6:
|
||||
if self.prefix.prefixlen <= 126:
|
||||
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
|
||||
return None
|
||||
|
||||
|
||||
class IPAddressManager(models.Manager):
|
||||
|
||||
@@ -440,7 +472,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
else:
|
||||
is_primary = False
|
||||
|
||||
return csv_format([
|
||||
return (
|
||||
self.address,
|
||||
self.vrf.rd if self.vrf else None,
|
||||
self.tenant.name if self.tenant else None,
|
||||
@@ -451,7 +483,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.interface.name if self.interface else None,
|
||||
is_primary,
|
||||
self.description,
|
||||
])
|
||||
)
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
@@ -481,6 +513,8 @@ class VLANGroup(models.Model):
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
|
||||
|
||||
csv_headers = ['name', 'slug', 'site']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = [
|
||||
@@ -496,6 +530,13 @@ class VLANGroup(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.site.name if self.site else None,
|
||||
)
|
||||
|
||||
def get_next_available_vid(self):
|
||||
"""
|
||||
Return the first available VLAN ID (1-4094) in the group.
|
||||
@@ -556,7 +597,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return csv_format([
|
||||
return (
|
||||
self.site.name if self.site else None,
|
||||
self.group.name if self.group else None,
|
||||
self.vid,
|
||||
@@ -565,7 +606,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.get_status_display(),
|
||||
self.role.name if self.role else None,
|
||||
self.description,
|
||||
])
|
||||
)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
@@ -576,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
def get_members(self):
|
||||
# Return all interfaces assigned to this VLAN
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Service(CreatedUpdatedModel):
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import unicode_literals
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
|
||||
@@ -36,6 +38,14 @@ UTILIZATION_GRAPH = """
|
||||
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
ROLE_PREFIX_COUNT = """
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
ROLE_VLAN_COUNT = """
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
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>
|
||||
@@ -48,13 +58,7 @@ PREFIX_LINK = """
|
||||
{% else %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.depth }}9px">
|
||||
{% endif %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
</span>
|
||||
"""
|
||||
|
||||
PREFIX_LINK_BRIEF = """
|
||||
<span style="padding-left: {{ record.depth }}0px">
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
</span>
|
||||
"""
|
||||
|
||||
@@ -135,11 +139,23 @@ VLANGROUP_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_UNTAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
<i class="glyphicon glyphicon-ok">
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_ACTIONS = """
|
||||
{% if perms.dcim.change_interface %}
|
||||
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TENANT_LINK = """
|
||||
{% if record.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}">{{ record.tenant }}</a>
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
||||
{% elif record.vrf.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}">{{ record.vrf.tenant }}</a>*
|
||||
<a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@@ -154,7 +170,7 @@ class VRFTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
rd = tables.Column(verbose_name='RD')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
@@ -178,17 +194,35 @@ class RIRTable(BaseTable):
|
||||
|
||||
|
||||
class RIRDetailTable(RIRTable):
|
||||
stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
|
||||
footer=lambda table: sum(r.stats['total'] for r in table.data))
|
||||
stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
|
||||
footer=lambda table: sum(r.stats['active'] for r in table.data))
|
||||
stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
|
||||
footer=lambda table: sum(r.stats['reserved'] for r in table.data))
|
||||
stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
|
||||
footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
|
||||
stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
|
||||
footer=lambda table: sum(r.stats['available'] for r in table.data))
|
||||
utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
|
||||
stats_total = tables.Column(
|
||||
accessor='stats.total',
|
||||
verbose_name='Total',
|
||||
footer=lambda table: sum(r.stats['total'] for r in table.data)
|
||||
)
|
||||
stats_active = tables.Column(
|
||||
accessor='stats.active',
|
||||
verbose_name='Active',
|
||||
footer=lambda table: sum(r.stats['active'] for r in table.data)
|
||||
)
|
||||
stats_reserved = tables.Column(
|
||||
accessor='stats.reserved',
|
||||
verbose_name='Reserved',
|
||||
footer=lambda table: sum(r.stats['reserved'] for r in table.data)
|
||||
)
|
||||
stats_deprecated = tables.Column(
|
||||
accessor='stats.deprecated',
|
||||
verbose_name='Deprecated',
|
||||
footer=lambda table: sum(r.stats['deprecated'] for r in table.data)
|
||||
)
|
||||
stats_available = tables.Column(
|
||||
accessor='stats.available',
|
||||
verbose_name='Available',
|
||||
footer=lambda table: sum(r.stats['available'] for r in table.data)
|
||||
)
|
||||
utilization = tables.TemplateColumn(
|
||||
template_code=RIR_UTILIZATION,
|
||||
verbose_name='Utilization'
|
||||
)
|
||||
|
||||
class Meta(RIRTable.Meta):
|
||||
fields = (
|
||||
@@ -225,10 +259,18 @@ class AggregateDetailTable(AggregateTable):
|
||||
|
||||
class RoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(verbose_name='Name')
|
||||
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')
|
||||
prefix_count = tables.TemplateColumn(
|
||||
accessor=Accessor('prefixes.count'),
|
||||
template_code=ROLE_PREFIX_COUNT,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
vlan_count = tables.TemplateColumn(
|
||||
accessor=Accessor('vlans.count'),
|
||||
template_code=ROLE_VLAN_COUNT,
|
||||
orderable=False,
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -245,7 +287,7 @@ class PrefixTable(BaseTable):
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK)
|
||||
tenant = tables.TemplateColumn(template_code=TENANT_LINK)
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
|
||||
role = tables.TemplateColumn(PREFIX_ROLE_LINK)
|
||||
@@ -274,7 +316,7 @@ class IPAddressTable(BaseTable):
|
||||
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
tenant = tables.TemplateColumn(TENANT_LINK)
|
||||
tenant = tables.TemplateColumn(template_code=TENANT_LINK)
|
||||
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
|
||||
interface = tables.Column(orderable=False)
|
||||
|
||||
@@ -305,7 +347,7 @@ class IPAddressAssignTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface')
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
orderable = False
|
||||
|
||||
|
||||
@@ -336,7 +378,7 @@ class VLANTable(BaseTable):
|
||||
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
role = tables.TemplateColumn(VLAN_ROLE_LINK)
|
||||
|
||||
@@ -350,3 +392,21 @@ class VLANDetailTable(VLANTable):
|
||||
|
||||
class Meta(VLANTable.Meta):
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
|
||||
name = tables.Column(verbose_name='Interface')
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
orderable=False
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('parent', 'name', 'untagged', 'actions')
|
||||
|
||||
@@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VRF.objects.count(), 4)
|
||||
@@ -55,6 +55,32 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vrf4.name, data['name'])
|
||||
self.assertEqual(vrf4.rd, data['rd'])
|
||||
|
||||
def test_create_vrf_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test VRF 4',
|
||||
'rd': '65000:4',
|
||||
},
|
||||
{
|
||||
'name': 'Test VRF 5',
|
||||
'rd': '65000:5',
|
||||
},
|
||||
{
|
||||
'name': 'Test VRF 6',
|
||||
'rd': '65000:6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VRF.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vrf(self):
|
||||
|
||||
data = {
|
||||
@@ -63,7 +89,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VRF.objects.count(), 3)
|
||||
@@ -114,7 +140,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(RIR.objects.count(), 4)
|
||||
@@ -122,6 +148,32 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(rir4.name, data['name'])
|
||||
self.assertEqual(rir4.slug, data['slug'])
|
||||
|
||||
def test_create_rir_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test RIR 4',
|
||||
'slug': 'test-rir-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test RIR 5',
|
||||
'slug': 'test-rir-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test RIR 6',
|
||||
'slug': 'test-rir-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(RIR.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_rir(self):
|
||||
|
||||
data = {
|
||||
@@ -130,7 +182,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(RIR.objects.count(), 3)
|
||||
@@ -183,7 +235,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Aggregate.objects.count(), 4)
|
||||
@@ -191,6 +243,32 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(str(aggregate4.prefix), data['prefix'])
|
||||
self.assertEqual(aggregate4.rir_id, data['rir'])
|
||||
|
||||
def test_create_aggregate_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'prefix': '100.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
{
|
||||
'prefix': '101.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
{
|
||||
'prefix': '102.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Aggregate.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
|
||||
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
|
||||
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
|
||||
|
||||
def test_update_aggregate(self):
|
||||
|
||||
data = {
|
||||
@@ -199,7 +277,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Aggregate.objects.count(), 3)
|
||||
@@ -250,7 +328,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Role.objects.count(), 4)
|
||||
@@ -258,6 +336,32 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(role4.name, data['name'])
|
||||
self.assertEqual(role4.slug, data['slug'])
|
||||
|
||||
def test_create_role_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Role 4',
|
||||
'slug': 'test-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Role 5',
|
||||
'slug': 'test-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Role 6',
|
||||
'slug': 'test-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Role.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_role(self):
|
||||
|
||||
data = {
|
||||
@@ -266,7 +370,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Role.objects.count(), 3)
|
||||
@@ -324,7 +428,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Prefix.objects.count(), 4)
|
||||
@@ -335,6 +439,29 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(prefix4.vlan_id, data['vlan'])
|
||||
self.assertEqual(prefix4.role_id, data['role'])
|
||||
|
||||
def test_create_prefix_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'prefix': '10.0.1.0/24',
|
||||
},
|
||||
{
|
||||
'prefix': '10.0.2.0/24',
|
||||
},
|
||||
{
|
||||
'prefix': '10.0.3.0/24',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Prefix.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
|
||||
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
|
||||
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
|
||||
|
||||
def test_update_prefix(self):
|
||||
|
||||
data = {
|
||||
@@ -346,7 +473,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Prefix.objects.count(), 3)
|
||||
@@ -365,7 +492,73 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Prefix.objects.count(), 2)
|
||||
|
||||
def test_available_ips(self):
|
||||
def test_list_available_prefixes(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'))
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Retrieve all available IPs
|
||||
response = self.client.get(url, **self.header)
|
||||
available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
|
||||
for i, p in enumerate(response.data):
|
||||
self.assertEqual(p['prefix'], available_prefixes[i])
|
||||
|
||||
def test_create_single_available_prefix(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Create four available prefixes with individual requests
|
||||
prefixes_to_be_created = [
|
||||
'192.0.2.0/30',
|
||||
'192.0.2.4/30',
|
||||
'192.0.2.8/30',
|
||||
'192.0.2.12/30',
|
||||
]
|
||||
for i in range(4):
|
||||
data = {
|
||||
'prefix_length': 30,
|
||||
'description': 'Test Prefix {}'.format(i + 1)
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
# Try to create one more prefix
|
||||
response = self.client.post(url, {'prefix_length': 30}, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_prefixes(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Try to create five /30s (only four are available)
|
||||
data = [
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 1'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 2'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 3'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 4'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 5'},
|
||||
]
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Verify that no prefixes were created (the entire /28 is still available)
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28')
|
||||
|
||||
# Create four /30s in a single request
|
||||
response = self.client.post(url, data[:4], format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
def test_list_available_ips(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
@@ -380,12 +573,17 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
|
||||
|
||||
# Create all six available IPs
|
||||
for i in range(6):
|
||||
def test_create_single_available_ip(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Create all four available IPs with individual requests
|
||||
for i in range(1, 5):
|
||||
data = {
|
||||
'description': 'Test IP {}'.format(i)
|
||||
}
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
@@ -394,6 +592,27 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_ips(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Try to create nine IPs (only eight are available)
|
||||
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Verify that no IPs were created (eight are still available)
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
# Create all eight available IPs in a single request
|
||||
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)] # 8 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
|
||||
class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
@@ -430,7 +649,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(IPAddress.objects.count(), 4)
|
||||
@@ -438,6 +657,29 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(str(ipaddress4.address), data['address'])
|
||||
self.assertEqual(ipaddress4.vrf_id, data['vrf'])
|
||||
|
||||
def test_create_ipaddress_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'address': '192.168.0.4/24',
|
||||
},
|
||||
{
|
||||
'address': '192.168.0.5/24',
|
||||
},
|
||||
{
|
||||
'address': '192.168.0.6/24',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(IPAddress.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['address'], data[0]['address'])
|
||||
self.assertEqual(response.data[1]['address'], data[1]['address'])
|
||||
self.assertEqual(response.data[2]['address'], data[2]['address'])
|
||||
|
||||
def test_update_ipaddress(self):
|
||||
|
||||
data = {
|
||||
@@ -446,7 +688,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(IPAddress.objects.count(), 3)
|
||||
@@ -497,7 +739,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLANGroup.objects.count(), 4)
|
||||
@@ -505,6 +747,32 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vlangroup4.name, data['name'])
|
||||
self.assertEqual(vlangroup4.slug, data['slug'])
|
||||
|
||||
def test_create_vlangroup_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test VLAN Group 4',
|
||||
'slug': 'test-vlan-group-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test VLAN Group 5',
|
||||
'slug': 'test-vlan-group-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test VLAN Group 6',
|
||||
'slug': 'test-vlan-group-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLANGroup.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vlangroup(self):
|
||||
|
||||
data = {
|
||||
@@ -513,7 +781,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLANGroup.objects.count(), 3)
|
||||
@@ -564,7 +832,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLAN.objects.count(), 4)
|
||||
@@ -572,6 +840,32 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vlan4.vid, data['vid'])
|
||||
self.assertEqual(vlan4.name, data['name'])
|
||||
|
||||
def test_create_vlan_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'vid': 4,
|
||||
'name': 'Test VLAN 4',
|
||||
},
|
||||
{
|
||||
'vid': 5,
|
||||
'name': 'Test VLAN 5',
|
||||
},
|
||||
{
|
||||
'vid': 6,
|
||||
'name': 'Test VLAN 6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLAN.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vlan(self):
|
||||
|
||||
data = {
|
||||
@@ -580,7 +874,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLAN.objects.count(), 3)
|
||||
@@ -649,7 +943,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Service.objects.count(), 4)
|
||||
@@ -659,6 +953,38 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(service4.protocol, data['protocol'])
|
||||
self.assertEqual(service4.port, data['port'])
|
||||
|
||||
def test_create_service_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 4',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 4,
|
||||
},
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 5',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 5,
|
||||
},
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 6',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 6,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Service.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_service(self):
|
||||
|
||||
data = {
|
||||
@@ -669,7 +995,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Service.objects.count(), 3)
|
||||
|
||||
@@ -51,6 +51,7 @@ urlpatterns = [
|
||||
url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
|
||||
|
||||
# IP addresses
|
||||
@@ -79,6 +80,7 @@ urlpatterns = [
|
||||
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
|
||||
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
|
||||
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
|
||||
|
||||
@@ -192,9 +192,15 @@ class RIRListView(ObjectListView):
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
|
||||
|
||||
# Find all consumed space for each prefix status (we ignore containers for this purpose).
|
||||
active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
|
||||
reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
|
||||
deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
|
||||
active_prefixes = netaddr.cidr_merge(
|
||||
[p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)]
|
||||
)
|
||||
reserved_prefixes = netaddr.cidr_merge(
|
||||
[p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)]
|
||||
)
|
||||
deprecated_prefixes = netaddr.cidr_merge(
|
||||
[p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)]
|
||||
)
|
||||
|
||||
# Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
|
||||
available_prefixes = (
|
||||
@@ -205,11 +211,11 @@ class RIRListView(ObjectListView):
|
||||
)
|
||||
|
||||
# Add the size of each metric to the RIR total.
|
||||
stats['total'] += aggregate.prefix.size / denominator
|
||||
stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
|
||||
stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
|
||||
stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
|
||||
stats['available'] += available_prefixes.size / denominator
|
||||
stats['total'] += int(aggregate.prefix.size / denominator)
|
||||
stats['active'] += int(netaddr.IPSet(active_prefixes).size / denominator)
|
||||
stats['reserved'] += int(netaddr.IPSet(reserved_prefixes).size / denominator)
|
||||
stats['deprecated'] += int(netaddr.IPSet(deprecated_prefixes).size / denominator)
|
||||
stats['available'] += int(available_prefixes.size / denominator)
|
||||
|
||||
# Calculate the percentage of total space for each prefix status.
|
||||
total = float(stats['total'])
|
||||
@@ -229,20 +235,6 @@ class RIRListView(ObjectListView):
|
||||
|
||||
return rirs
|
||||
|
||||
def extra_context(self):
|
||||
|
||||
totals = {
|
||||
'total': sum([rir.stats['total'] for rir in self.queryset]),
|
||||
'active': sum([rir.stats['active'] for rir in self.queryset]),
|
||||
'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
|
||||
'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
|
||||
'available': sum([rir.stats['available'] for rir in self.queryset]),
|
||||
}
|
||||
|
||||
return {
|
||||
'totals': totals,
|
||||
}
|
||||
|
||||
|
||||
class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_rir'
|
||||
@@ -476,23 +468,38 @@ class PrefixView(View):
|
||||
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
|
||||
duplicate_prefix_table.exclude = ('vrf',)
|
||||
|
||||
return render(request, 'ipam/prefix.html', {
|
||||
'prefix': prefix,
|
||||
'aggregate': aggregate,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
})
|
||||
|
||||
|
||||
class PrefixPrefixesView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
|
||||
|
||||
# Child prefixes table
|
||||
child_prefixes = Prefix.objects.filter(
|
||||
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
|
||||
).select_related(
|
||||
child_prefixes = prefix.get_child_prefixes().select_related(
|
||||
'site', 'vlan', 'role',
|
||||
).annotate_depth(limit=0)
|
||||
|
||||
# Annotate available prefixes
|
||||
if child_prefixes:
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
child_prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
child_prefix_table.columns.show('pk')
|
||||
prefix_table.columns.show('pk')
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(child_prefix_table)
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
@@ -501,15 +508,12 @@ class PrefixView(View):
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix.html', {
|
||||
return render(request, 'ipam/prefix_prefixes.html', {
|
||||
'prefix': prefix,
|
||||
'aggregate': aggregate,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'child_prefix_table': child_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf or '0', prefix.prefix),
|
||||
'first_available_prefix': prefix.get_first_available_prefix(),
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'return_url': prefix.get_absolute_url(),
|
||||
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
})
|
||||
|
||||
|
||||
@@ -544,6 +548,7 @@ class PrefixIPAddressesView(View):
|
||||
|
||||
return render(request, 'ipam/prefix_ipaddresses.html', {
|
||||
'prefix': prefix,
|
||||
'first_available_ip': prefix.get_first_available_ip(),
|
||||
'ip_table': ip_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
@@ -716,8 +721,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
|
||||
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
|
||||
).filter(
|
||||
vrf=form.cleaned_data['vrf'],
|
||||
address__net_host=form.cleaned_data['address'],
|
||||
)
|
||||
address__istartswith=form.cleaned_data['address'],
|
||||
)[:100] # Limit to 100 results
|
||||
table = tables.IPAddressAssignTable(queryset)
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
@@ -838,6 +843,38 @@ class VLANView(View):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
|
||||
members = vlan.get_members().select_related('device', 'virtual_machine')
|
||||
|
||||
members_table = tables.VLANMemberTable(members)
|
||||
# if request.user.has_perm('dcim.change_interface'):
|
||||
# members_table.columns.show('pk')
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
# permissions = {
|
||||
# 'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
# 'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
# }
|
||||
|
||||
return render(request, 'ipam/vlan_members.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
# 'permissions': permissions,
|
||||
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
})
|
||||
|
||||
|
||||
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vlan'
|
||||
model = VLAN
|
||||
|
||||
@@ -20,6 +20,9 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
|
||||
def show_form_for_method(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def get_filter_form(self, data, view, request):
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Authentication
|
||||
|
||||
@@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = (
|
||||
('rack', 'Racks'),
|
||||
('devicetype', 'Device types'),
|
||||
('device', 'Devices'),
|
||||
('virtualchassis', 'Virtual Chassis'),
|
||||
)),
|
||||
('IPAM', (
|
||||
('vrf', 'VRFs'),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -12,8 +14,15 @@ except ImportError:
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
|
||||
# Raise a deprecation warning for Python 2.x
|
||||
if sys.version_info[0] < 3:
|
||||
warnings.warn(
|
||||
"Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest "
|
||||
"opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.2.7'
|
||||
VERSION = '2.3.7'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -124,7 +133,7 @@ INSTALLED_APPS = (
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'rest_framework_swagger',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
'dcim',
|
||||
'ipam',
|
||||
@@ -134,6 +143,7 @@ INSTALLED_APPS = (
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
'drf_yasg',
|
||||
)
|
||||
|
||||
# Middleware
|
||||
@@ -236,6 +246,40 @@ REST_FRAMEWORK = {
|
||||
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
|
||||
}
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||
'drf_yasg.inspectors.RelatedFieldInspector',
|
||||
'drf_yasg.inspectors.ChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.FileFieldInspector',
|
||||
'drf_yasg.inspectors.DictFieldInspector',
|
||||
'drf_yasg.inspectors.SimpleFieldInspector',
|
||||
'drf_yasg.inspectors.StringDefaultFieldInspector',
|
||||
],
|
||||
'DEFAULT_FILTER_INSPECTORS': [
|
||||
'utilities.custom_inspectors.IdInFilterInspector',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
],
|
||||
'DEFAULT_PAGINATOR_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullablePaginatorInspector',
|
||||
'drf_yasg.inspectors.DjangoRestResponsePagination',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
],
|
||||
'SECURITY_DEFINITIONS': {
|
||||
'Bearer': {
|
||||
'type': 'apiKey',
|
||||
'name': 'Authorization',
|
||||
'in': 'header',
|
||||
}
|
||||
},
|
||||
'VALIDATOR_URL': None,
|
||||
}
|
||||
|
||||
|
||||
# Django debug toolbar
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
@@ -245,5 +289,5 @@ INTERNAL_IPS = (
|
||||
|
||||
try:
|
||||
HOSTNAME = socket.gethostname()
|
||||
except:
|
||||
except Exception:
|
||||
HOSTNAME = 'localhost'
|
||||
|
||||
@@ -4,12 +4,24 @@ from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.static import serve
|
||||
from rest_framework_swagger.views import get_swagger_view
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
from netbox.views import APIRootView, HomeView, SearchView
|
||||
from users.views import LoginView, LogoutView
|
||||
|
||||
swagger_view = get_swagger_view(title='NetBox API')
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="NetBox API",
|
||||
default_version='v2',
|
||||
description="API to access NetBox",
|
||||
terms_of_service="https://github.com/digitalocean/netbox",
|
||||
contact=openapi.Contact(email="netbox@digitalocean.com"),
|
||||
license=openapi.License(name="Apache v2 License"),
|
||||
),
|
||||
validators=['flex', 'ssv'],
|
||||
public=True,
|
||||
)
|
||||
|
||||
_patterns = [
|
||||
|
||||
@@ -40,7 +52,9 @@ _patterns = [
|
||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||
url(r'^api/virtualization/', include('virtualization.api.urls')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'),
|
||||
url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'),
|
||||
url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
@@ -60,3 +74,5 @@ if settings.DEBUG:
|
||||
urlpatterns = [
|
||||
url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
|
||||
]
|
||||
|
||||
handler500 = 'utilities.views.server_error'
|
||||
|
||||
@@ -12,10 +12,10 @@ from rest_framework.views import APIView
|
||||
from circuits.filters import CircuitFilter, ProviderFilter
|
||||
from circuits.models import Circuit, Provider
|
||||
from circuits.tables import CircuitTable, ProviderTable
|
||||
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
|
||||
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
|
||||
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
|
||||
from extras.models import TopologyMap, UserAction
|
||||
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter
|
||||
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis
|
||||
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable
|
||||
from extras.models import ReportResult, TopologyMap, UserAction
|
||||
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||
@@ -72,6 +72,12 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': DeviceDetailTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')),
|
||||
'filter': VirtualChassisFilter,
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
# IPAM
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.select_related('tenant'),
|
||||
@@ -119,7 +125,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.all(),
|
||||
'queryset': Cluster.objects.select_related('type', 'group'),
|
||||
'filter': ClusterFilter,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
@@ -177,6 +183,7 @@ class HomeView(View):
|
||||
'search_form': SearchForm(),
|
||||
'stats': stats,
|
||||
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
|
||||
'report_results': ReportResult.objects.order_by('-created')[:10],
|
||||
'recent_activity': UserAction.objects.select_related('user')[:50]
|
||||
})
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ input[name="pk"] {
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
|
||||
th.pk, td.pk {
|
||||
padding-bottom: 6px;
|
||||
padding-top: 10px;
|
||||
width: 30px;
|
||||
@@ -372,12 +372,19 @@ table.reports td.method {
|
||||
font-family: monospace;
|
||||
padding-left: 30px;
|
||||
}
|
||||
table.reports td.stats label {
|
||||
td.report-stats label {
|
||||
display: inline-block;
|
||||
line-height: 14px;
|
||||
margin-bottom: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
table.report th {
|
||||
position: relative;
|
||||
}
|
||||
table.report th a {
|
||||
position: absolute;
|
||||
top: -51px;
|
||||
}
|
||||
|
||||
/* AJAX loader */
|
||||
.loading {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
I hope you love Font Awesome. If you've found it useful, please do me a favor and check out my latest project,
|
||||
Fort Awesome (https://fortawesome.com). It makes it easy to put the perfect icons on your website. Choose from our awesome,
|
||||
comprehensive icon sets or copy and paste your own.
|
||||
|
||||
Please. Check it out.
|
||||
|
||||
-Dave Gandy
|
||||
@@ -1,14 +1,24 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// "Toggle all" checkbox (table header)
|
||||
$('#toggle_all').click(function() {
|
||||
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
// "Toggle" checkbox for object lists (PK column)
|
||||
$('input:checkbox.toggle').click(function() {
|
||||
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
|
||||
// Show the "select all" box if present
|
||||
if ($(this).is(':checked')) {
|
||||
$('#select_all_box').removeClass('hidden');
|
||||
} else {
|
||||
$('#select_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Uncheck the "toggle" and "select all" checkboxes if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$('input:checkbox.toggle, #select_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable hidden buttons when "select all" is checked
|
||||
$('#select_all').click(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
@@ -17,21 +27,6 @@ $(document).ready(function() {
|
||||
$('#select_all_box').find('button').prop('disabled', 'disabled');
|
||||
}
|
||||
});
|
||||
// Uncheck the "toggle all" checkbox if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$('#select_all, #toggle_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple "Toggle all" button (panel)
|
||||
$('button.toggle').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
|
||||
return false;
|
||||
});
|
||||
|
||||
// Slugify
|
||||
function slugify(s, num_chars) {
|
||||
@@ -71,59 +66,65 @@ $(document).ready(function() {
|
||||
$('select[filter-for]').change(function() {
|
||||
|
||||
// Resolve child field by ID specified in parent
|
||||
var child_name = $(this).attr('filter-for');
|
||||
var child_field = $('#id_' + child_name);
|
||||
var child_selected = child_field.val();
|
||||
var child_names = $(this).attr('filter-for');
|
||||
var parent = this;
|
||||
|
||||
// Wipe out any existing options within the child field and create a default option
|
||||
child_field.empty();
|
||||
if (!child_field.attr('multiple')) {
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
}
|
||||
// allow more than one child
|
||||
$.each(child_names.split(" "), function(_, child_name){
|
||||
|
||||
if ($(this).val() || $(this).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
var child_field = $('#id_' + child_name);
|
||||
var child_selected = child_field.val();
|
||||
|
||||
// Determine the filter fields needed to make an API call
|
||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||
var match;
|
||||
var rendered_url = api_url;
|
||||
while (match = filter_regex.exec(api_url)) {
|
||||
var filter_field = $('#id_' + match[1]);
|
||||
if (filter_field.val()) {
|
||||
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||
} else if (filter_field.attr('nullable') == 'true') {
|
||||
rendered_url = rendered_url.replace(match[0], '0');
|
||||
}
|
||||
// Wipe out any existing options within the child field and create a default option
|
||||
child_field.empty();
|
||||
if (!child_field.attr('multiple')) {
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
}
|
||||
|
||||
// If all URL variables have been replaced, make the API call
|
||||
if (rendered_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + rendered_url);
|
||||
$.ajax({
|
||||
url: rendered_url,
|
||||
dataType: 'json',
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
||||
option.attr("disabled", "disabled");
|
||||
} else if (choice.id == child_selected) {
|
||||
option.attr("selected", "selected");
|
||||
}
|
||||
child_field.append(option);
|
||||
});
|
||||
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
|
||||
// Determine the filter fields needed to make an API call
|
||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||
var match;
|
||||
var rendered_url = api_url;
|
||||
while (match = filter_regex.exec(api_url)) {
|
||||
var filter_field = $('#id_' + match[1]);
|
||||
if (filter_field.val()) {
|
||||
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||
} else if (filter_field.attr('nullable') == 'true') {
|
||||
rendered_url = rendered_url.replace(match[0], '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If all URL variables have been replaced, make the API call
|
||||
if (rendered_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + rendered_url);
|
||||
$.ajax({
|
||||
url: rendered_url,
|
||||
dataType: 'json',
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
||||
option.attr("disabled", "disabled");
|
||||
} else if (choice.id == child_selected) {
|
||||
option.attr("selected", "selected");
|
||||
}
|
||||
child_field.append(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Trigger change event in case the child field is the parent of another field
|
||||
child_field.change();
|
||||
// Trigger change event in case the child field is the parent of another field
|
||||
child_field.change();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -45,13 +45,20 @@ class WritableSecretSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext']
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Encrypt plaintext data using the master key provided from the view context
|
||||
if data.get('plaintext'):
|
||||
s = Secret(plaintext=data['plaintext'])
|
||||
s.encrypt(self.context['master_key'])
|
||||
data['ciphertext'] = s.ciphertext
|
||||
data['hash'] = s.hash
|
||||
|
||||
# Validate uniqueness of name if one has been provided.
|
||||
if data.get('name', None):
|
||||
if data.get('name'):
|
||||
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
@@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet):
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class SecretViewSet(ModelViewSet):
|
||||
queryset = Secret.objects.select_related(
|
||||
'device__primary_ip4', 'device__primary_ip6', 'role',
|
||||
).prefetch_related(
|
||||
@@ -56,17 +56,13 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
|
||||
master_key = None
|
||||
|
||||
def _get_encrypted_fields(self, serializer):
|
||||
"""
|
||||
Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
|
||||
ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
|
||||
"""
|
||||
s = Secret(plaintext=serializer.validated_data['plaintext'])
|
||||
s.encrypt(self.master_key)
|
||||
return ({
|
||||
'ciphertext': s.ciphertext,
|
||||
'hash': s.hash,
|
||||
})
|
||||
def get_serializer_context(self):
|
||||
|
||||
# Make the master key available to the serializer for encrypting plaintext values
|
||||
context = super(SecretViewSet, self).get_serializer_context()
|
||||
context['master_key'] = self.master_key
|
||||
|
||||
return context
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
|
||||
@@ -128,12 +124,6 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
|
||||
class GetSessionKeyViewSet(ViewSet):
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ def validate_rsa_key(key, is_secret=True):
|
||||
raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.")
|
||||
try:
|
||||
PKCS1_OAEP.new(key)
|
||||
except:
|
||||
except Exception:
|
||||
raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = SecretRole
|
||||
fields = ['name', 'slug']
|
||||
fields = SecretRole.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name of secret role',
|
||||
}
|
||||
@@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
|
||||
#
|
||||
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
plaintext = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
|
||||
)
|
||||
plaintext2 = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(SecretForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# A plaintext value is required when creating a new Secret
|
||||
if not self.instance.pk:
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that the provided plaintext values match
|
||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||
raise forms.ValidationError({
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
@@ -98,7 +115,7 @@ class SecretCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['device', 'role', 'name', 'plaintext']
|
||||
fields = Secret.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Name or username',
|
||||
}
|
||||
@@ -136,7 +153,8 @@ class UserKeyForm(BootstrapMixin, forms.ModelForm):
|
||||
model = UserKey
|
||||
fields = ['public_key']
|
||||
help_texts = {
|
||||
'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption.",
|
||||
'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption. "
|
||||
"Please note that passphrase-protected keys are not supported.",
|
||||
}
|
||||
|
||||
def clean_public_key(self):
|
||||
|
||||
@@ -87,7 +87,7 @@ class UserKey(CreatedUpdatedModel):
|
||||
raise ValidationError({
|
||||
'public_key': "Invalid RSA key format."
|
||||
})
|
||||
except:
|
||||
except Exception:
|
||||
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
|
||||
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
|
||||
|
||||
@@ -239,6 +239,8 @@ class SecretRole(models.Model):
|
||||
users = models.ManyToManyField(User, related_name='secretroles', blank=True)
|
||||
groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
|
||||
|
||||
csv_headers = ['name', 'slug']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -248,6 +250,12 @@ class SecretRole(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
)
|
||||
|
||||
def has_member(self, user):
|
||||
"""
|
||||
Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.
|
||||
|
||||
@@ -81,12 +81,12 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
def test_create_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test SecretRole 4',
|
||||
'slug': 'test-secretrole-4',
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 4)
|
||||
@@ -94,6 +94,32 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(secretrole4.name, data['name'])
|
||||
self.assertEqual(secretrole4.slug, data['slug'])
|
||||
|
||||
def test_create_secretrole_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 5',
|
||||
'slug': 'test-secret-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 6',
|
||||
'slug': 'test-secret-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_secretrole(self):
|
||||
|
||||
data = {
|
||||
@@ -102,7 +128,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(SecretRole.objects.count(), 3)
|
||||
@@ -138,9 +164,9 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
self.plaintext = {
|
||||
'secret1': 'Secret#1Plaintext',
|
||||
'secret2': 'Secret#2Plaintext',
|
||||
'secret3': 'Secret#3Plaintext',
|
||||
'secret1': 'Secret #1 Plaintext',
|
||||
'secret2': 'Secret #2 Plaintext',
|
||||
'secret3': 'Secret #3 Plaintext',
|
||||
}
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
@@ -187,11 +213,12 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'plaintext': 'Secret#4Plaintext',
|
||||
'name': 'Test Secret 4',
|
||||
'plaintext': 'Secret #4 Plaintext',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
@@ -201,6 +228,38 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(secret4.role_id, data['role'])
|
||||
self.assertEqual(secret4.plaintext, data['plaintext'])
|
||||
|
||||
def test_create_secret_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 4',
|
||||
'plaintext': 'Secret #4 Plaintext',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 5',
|
||||
'plaintext': 'Secret #5 Plaintext',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 6',
|
||||
'plaintext': 'Secret #6 Plaintext',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Secret.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
|
||||
self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
|
||||
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
|
||||
|
||||
def test_update_secret(self):
|
||||
|
||||
data = {
|
||||
@@ -210,7 +269,7 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
|
||||
<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
|
||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
|
||||
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<strong>Circuit</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<span class="label label-{{ circuit.get_status_class }}">{{ circuit.get_status_display }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Provider</td>
|
||||
<td>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{% render_field form.provider %}
|
||||
{% render_field form.cid %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.install_date %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.add_circuit %}
|
||||
<a href="{% url 'circuits:circuit_add' %}" class="btn btn-primary">
|
||||
<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>
|
||||
{% add_button 'circuits:circuit_add' %}
|
||||
{% import_button 'circuits:circuit_import' %}
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='circuits' %}
|
||||
{% export_button content_type %}
|
||||
</div>
|
||||
<h1>{% block title %}Circuits{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.add_circuittype %}
|
||||
<a href="{% url 'circuits:circuittype_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a circuit type
|
||||
</a>
|
||||
<a href="{% url 'circuits:circuittype_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import circuit types
|
||||
</a>
|
||||
{% add_button 'circuits:circuittype_add' %}
|
||||
{% import_button 'circuits:circuittype_import' %}
|
||||
{% endif %}
|
||||
{% export_button content_type %}
|
||||
</div>
|
||||
<h1>{% block title %}Circuit Types{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.add_provider %}
|
||||
<a href="{% url 'circuits:provider_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a provider
|
||||
</a>
|
||||
<a href="{% url 'circuits:provider_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import providers
|
||||
</a>
|
||||
{% add_button 'circuits:provider_add' %}
|
||||
{% import_button 'circuits:provider_import' %}
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='providers' %}
|
||||
{% export_button content_type %}
|
||||
</div>
|
||||
<h1>{% block title %}Providers{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
||||
55
netbox/templates/dcim/bulk_rename.html
Normal file
55
netbox/templates/dcim/bulk_rename.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Current Name</th>
|
||||
<th>New Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in selected_objects %}
|
||||
<tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}>
|
||||
<td>{{ obj.name }}</td>
|
||||
<td>{{ obj.new_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<form action="" method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Rename</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group text-right">
|
||||
<div class="col-md-12">
|
||||
<button type="submit" name="_preview" class="btn btn-primary">Preview</button>
|
||||
{% if '_preview' in request.POST and not form.errors %}
|
||||
<button type="submit" name="_apply" class="btn btn-primary">Apply</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,12 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
<a href="{% url 'dcim:console_connections_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import connections
|
||||
</a>
|
||||
{% import_button 'dcim:console_connections_import' %}
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='connections' %}
|
||||
{% export_button content_type %}
|
||||
</div>
|
||||
<h1>{% block title %}Console Connections{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
||||
@@ -98,6 +98,46 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% if vc_members %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Virtual Chassis</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Position</th>
|
||||
<th>Master</th>
|
||||
<th>Priority</th>
|
||||
</tr>
|
||||
{% for vc_member in vc_members %}
|
||||
<tr{% if vc_member == device %} class="info"{% endif %}>
|
||||
<td>
|
||||
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
||||
</td>
|
||||
<td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
|
||||
<td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
|
||||
<td>{{ vc_member.vc_priority|default:"" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="panel-footer text-right">
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
|
||||
</a>
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Management</strong>
|
||||
@@ -106,7 +146,7 @@
|
||||
<tr>
|
||||
<td>Role</td>
|
||||
<td>
|
||||
<a href="{{ device.device_role.get_absolute_url }}">{{ device.device_role }}</a>
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ device.device_role.slug }}">{{ device.device_role }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -339,45 +379,49 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Device Bays</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th colspan="2">Installed Device</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/devicebay.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No device bays defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if device_bays and perms.dcim.change_devicebay %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/devicebay.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No device bays defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
|
||||
<div class="panel-footer">
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.dcim.delete_devicebay %}
|
||||
</form>
|
||||
@@ -396,66 +440,61 @@
|
||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted">— No interfaces defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interfaceconnection %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8">No interfaces defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interfaceconnection %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.dcim.delete_interface %}
|
||||
</form>
|
||||
@@ -469,58 +508,51 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Console Server Ports</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for csp in cs_ports %}
|
||||
{% include 'dcim/inc/consoleserverport.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No console server ports defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if cs_ports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for csp in cs_ports %}
|
||||
{% include 'dcim/inc/consoleserverport.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No console server ports defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<div class="panel-footer">
|
||||
{% if cs_ports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.delete_consoleserverport %}
|
||||
</form>
|
||||
@@ -534,58 +566,51 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Outlets</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_poweroutlet and cs_ports|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for po in power_outlets %}
|
||||
{% include 'dcim/inc/poweroutlet.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No power outlets defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if power_outlets and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for po in power_outlets %}
|
||||
{% include 'dcim/inc/poweroutlet.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No power outlets defined</td>
|
||||
</tr> text-nowrap
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<div class="panel-footer">
|
||||
{% if power_outlets and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.delete_poweroutlet %}
|
||||
</form>
|
||||
|
||||
@@ -64,13 +64,14 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-success">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add Inventory Item
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.add_device %}
|
||||
<a href="{% url 'dcim:device_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a device
|
||||
</a>
|
||||
<a href="{% url 'dcim:device_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import devices
|
||||
</a>
|
||||
{% add_button 'dcim:device_add' %}
|
||||
{% import_button 'dcim:device_import' %}
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='devices' %}
|
||||
{% export_button content_type %}
|
||||
</div>
|
||||
<h1>{% block title %}Devices{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
||||
@@ -53,14 +53,15 @@ $(document).ready(function() {
|
||||
success: function(json) {
|
||||
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
|
||||
var neighbor = neighbors[0];
|
||||
var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
|
||||
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
|
||||
|
||||
// Glean configured hostnames/interfaces from the DOM
|
||||
var configured_device = row.children('td.configured_device').attr('data');
|
||||
var configured_interface = row.children('td.configured_interface').attr('data');
|
||||
var configured_interface_short = null;
|
||||
if (configured_interface) {
|
||||
// Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1).
|
||||
configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
|
||||
configured_interface_short = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
|
||||
}
|
||||
|
||||
// Clean up hostnames/interfaces learned via LLDP
|
||||
@@ -76,6 +77,8 @@ $(document).ready(function() {
|
||||
row.addClass('info');
|
||||
} else if (configured_device == lldp_device && configured_interface == lldp_interface) {
|
||||
row.addClass('success');
|
||||
} else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
|
||||
row.addClass('success');
|
||||
} else {
|
||||
row.addClass('danger');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user