mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-03 02:57:45 -06:00
Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d286fbdc3 | ||
|
|
afc58e6bff | ||
|
|
f59b5119e5 | ||
|
|
8eca7377a5 | ||
|
|
e639de9861 | ||
|
|
9a4d885a9c | ||
|
|
d28cc4fdb5 | ||
|
|
54a979c936 | ||
|
|
d18652f726 | ||
|
|
ac1083f65d | ||
|
|
bcfc2e5f74 | ||
|
|
a135396d7b | ||
|
|
ef987bd008 | ||
|
|
a2fef1df83 | ||
|
|
016c7d4e6f | ||
|
|
0a60bcdb8d | ||
|
|
5374e6c881 | ||
|
|
ab65ab860f | ||
|
|
ad2742dbb2 | ||
|
|
997077fc5d | ||
|
|
c8042ad511 | ||
|
|
b51d66ea72 | ||
|
|
0321ae0a7f | ||
|
|
e4ecd2dae9 | ||
|
|
335cfcde57 | ||
|
|
91fe80f73c | ||
|
|
7e437455c1 | ||
|
|
f9f1a4439f | ||
|
|
68082a88a7 | ||
|
|
a94e5c7403 | ||
|
|
c061f7021e | ||
|
|
ad43373f5b | ||
|
|
720b88048d | ||
|
|
d58291d119 | ||
|
|
132b1ff479 | ||
|
|
cb9478e0ea | ||
|
|
04964cc52b | ||
|
|
9990fd25d4 | ||
|
|
e9930854c4 | ||
|
|
11ac4cd2c2 | ||
|
|
5b07c77708 | ||
|
|
c3ad2d0a80 | ||
|
|
1cc82f2ab7 | ||
|
|
358d7ac562 | ||
|
|
cae784ff52 | ||
|
|
835e11d10f | ||
|
|
e7157973e7 | ||
|
|
5b5110acbf | ||
|
|
61e5eff666 | ||
|
|
16a3d1339a | ||
|
|
759190d8ba | ||
|
|
f8e2cb06d1 | ||
|
|
37aa10c5a5 | ||
|
|
ad9ed13b9a | ||
|
|
d9f8d809c8 | ||
|
|
03054b88aa | ||
|
|
a72911b527 | ||
|
|
8e4da3faf4 | ||
|
|
46945aa98d | ||
|
|
939224d0af | ||
|
|
8d15f79f1e | ||
|
|
b079cc12f4 | ||
|
|
ad1da22257 | ||
|
|
fa8e70fe26 | ||
|
|
a87f34ebb3 | ||
|
|
75d875615b | ||
|
|
f63cf6a7a4 | ||
|
|
18ab1144bb | ||
|
|
a9b216d212 | ||
|
|
6b19a1ece9 | ||
|
|
0dd6d552a8 | ||
|
|
cfddf570b9 | ||
|
|
d2c8aae59c | ||
|
|
b1cd634ab4 | ||
|
|
47abd62c55 | ||
|
|
db781437fc | ||
|
|
a40f52ee62 | ||
|
|
89e6fd68e5 | ||
|
|
ecf0f15c17 | ||
|
|
04a6e2de9d | ||
|
|
0cd29daea2 | ||
|
|
b392502b9b | ||
|
|
a301c974e4 | ||
|
|
4c7c2edf9a | ||
|
|
3d3748d6f5 | ||
|
|
fa3199d41c | ||
|
|
efbda6d5af | ||
|
|
8640f500d1 | ||
|
|
3d90e3aee9 | ||
|
|
6c676d21c3 | ||
|
|
7e6af88966 | ||
|
|
3b53cf5e84 | ||
|
|
713f02ca3f | ||
|
|
19844e81d1 | ||
|
|
5fbe766a0a | ||
|
|
1430c0a6e6 | ||
|
|
e3e928f1c4 | ||
|
|
e155acbbd4 | ||
|
|
be1b6b6aa3 | ||
|
|
b4ba5cbb7a | ||
|
|
f28474d86e | ||
|
|
1964073072 | ||
|
|
856d2e3176 | ||
|
|
3409a1bfba | ||
|
|
fc8f02c180 | ||
|
|
03e48161a1 | ||
|
|
def63329f0 | ||
|
|
0680b01a96 | ||
|
|
592e788a7d | ||
|
|
aabc1a8265 | ||
|
|
a23ff4e519 | ||
|
|
edc015d9bf | ||
|
|
d5a0e12283 | ||
|
|
90e8f26cd4 | ||
|
|
d4e83ca1c0 | ||
|
|
137aa9da2c | ||
|
|
87c600aa7c | ||
|
|
08dfe64301 | ||
|
|
0e48ee5f9e | ||
|
|
b6e532f01d | ||
|
|
60baa5e59e | ||
|
|
9eb64dc6a4 | ||
|
|
3de04094fb | ||
|
|
02e8979178 | ||
|
|
5e962719ca | ||
|
|
fefc623343 | ||
|
|
5c40081d84 | ||
|
|
e739d6aa05 | ||
|
|
0994719b91 | ||
|
|
f469920759 | ||
|
|
3c9be8cd08 | ||
|
|
a0e82e1817 | ||
|
|
69bf451b20 | ||
|
|
58699a220b | ||
|
|
3f70f685bb | ||
|
|
d838a76461 | ||
|
|
e13d96a6f2 | ||
|
|
1e1e2d5f54 | ||
|
|
c51d2a56ac | ||
|
|
e9d888bf63 | ||
|
|
47b7ec8d00 | ||
|
|
c8f09f28b1 | ||
|
|
5a32b9599a | ||
|
|
a6cb7965dc | ||
|
|
601cbd2306 | ||
|
|
a77658a6bf | ||
|
|
4a2d2882c6 | ||
|
|
0accaedad0 | ||
|
|
aa10430c7b | ||
|
|
98983e7e1a | ||
|
|
3441216aca | ||
|
|
d16a7e108c | ||
|
|
359ae5d116 | ||
|
|
a9a2509d39 | ||
|
|
e73c225965 | ||
|
|
39e6872288 | ||
|
|
af3c4905ea | ||
|
|
7873952e7a | ||
|
|
d989ce2b70 | ||
|
|
249948e174 | ||
|
|
8ae3331d04 | ||
|
|
b2e05aafc1 | ||
|
|
cc1a43e5d9 | ||
|
|
6f39e6599d | ||
|
|
1fe5857411 | ||
|
|
fce61295c9 | ||
|
|
396b0dace8 | ||
|
|
fe2e33a9e1 | ||
|
|
8d9d4cec05 | ||
|
|
ddd10ba8af | ||
|
|
e4f22bc494 | ||
|
|
09633ee11b | ||
|
|
dc6dbdf3c4 | ||
|
|
8f4197c020 | ||
|
|
5fe5fd71b5 | ||
|
|
77e2b0e4ba | ||
|
|
e196d82e76 | ||
|
|
23f334e5df | ||
|
|
c5a265e828 | ||
|
|
278c881059 | ||
|
|
b09112941a | ||
|
|
158ed5765b | ||
|
|
1e5778e6db | ||
|
|
442dee72b5 | ||
|
|
f2d028ba29 | ||
|
|
b1402f3bae | ||
|
|
6ca9e2a777 | ||
|
|
dac93b9840 | ||
|
|
34a0e2e72d | ||
|
|
af2777c580 | ||
|
|
4a066a1913 | ||
|
|
91083fd6c5 | ||
|
|
202e7d0ebf | ||
|
|
7449f36f22 | ||
|
|
dc6e7cd56c | ||
|
|
26426c1431 | ||
|
|
2fba20acda | ||
|
|
3c887b0dd9 | ||
|
|
52c9e3ecef | ||
|
|
2ffdc46f81 | ||
|
|
577c86fb80 | ||
|
|
4ce7dfa55e | ||
|
|
bd144c996a | ||
|
|
2c1a60b965 | ||
|
|
2dad44c93f | ||
|
|
c835ec5102 | ||
|
|
e2d17b1999 | ||
|
|
3c2ddf1f69 | ||
|
|
d64fc261e2 | ||
|
|
34643f536e | ||
|
|
6a3dab0142 | ||
|
|
3c2b2f26b9 | ||
|
|
67f9e16905 | ||
|
|
14a46f82ee | ||
|
|
974500c281 | ||
|
|
1f4954bf0e | ||
|
|
9615dc0638 | ||
|
|
c1e56a3717 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [jeremystretch]
|
||||
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report a reproducible bug in the current release of NetBox
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
|
||||
|
||||
This form is only for reporting reproducible bugs. If you need assistance
|
||||
with NetBox installation, or if you have a general question, please start a
|
||||
discussion instead: https://github.com/netbox-community/netbox/discussions
|
||||
|
||||
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, and that any plugins have been disabled.
|
||||
-->
|
||||
### Environment
|
||||
* Python version:
|
||||
* NetBox version:
|
||||
|
||||
<!--
|
||||
Describe in detail the exact steps that someone else can take to reproduce
|
||||
this bug using the current stable release of NetBox. Begin with the
|
||||
creation of any necessary database objects and call out every operation
|
||||
being performed explicitly. If reporting a bug in the REST API, be sure to
|
||||
reconstruct the raw HTTP request(s) being made: Don't rely on a client
|
||||
library such as pynetbox.
|
||||
-->
|
||||
### Steps to Reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
<!-- What did you expect to happen? -->
|
||||
### Expected Behavior
|
||||
|
||||
|
||||
<!-- What happened instead? -->
|
||||
### Observed Behavior
|
||||
63
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report a reproducible bug in the current release of NetBox
|
||||
labels: ["type: bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**NOTE:** This form is only for reporting _reproducible bugs_ in a
|
||||
current NetBox installation. If you're having trouble with installation or just
|
||||
looking for assistance with using NetBox, please visit our
|
||||
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead."
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: "What version of NetBox are you currently running?"
|
||||
placeholder: v2.10.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Python version
|
||||
description: "What version of Python are you currently running?"
|
||||
options:
|
||||
- 3.6
|
||||
- 3.7
|
||||
- 3.8
|
||||
- 3.9
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: "Describe in detail the exact steps that someone else can take to
|
||||
reproduce this bug using the current stable release of NetBox. Begin with the
|
||||
creation of any necessary database objects and call out every operation being
|
||||
performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
|
||||
the raw HTTP request(s) being made: Don't rely on a client library such as
|
||||
pynetbox."
|
||||
placeholder: |
|
||||
1. Click on "create widget"
|
||||
2. Set foo to 12 and bar to G
|
||||
3. Click the "create" button
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: "What did you expect to happen?"
|
||||
placeholder: "A new widget should have been created with the specified attributes"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Observed Behavior
|
||||
description: "What happened instead?"
|
||||
placeholder: "A TypeError exception was raised"
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Additional information
|
||||
You can use the space below to provide any additional information or to attach files.
|
||||
28
.github/ISSUE_TEMPLATE/documentation_change.md
vendored
28
.github/ISSUE_TEMPLATE/documentation_change.md
vendored
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: 📖 Documentation Change
|
||||
about: Suggest an addition or modification to the NetBox documentation
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
|
||||
|
||||
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.)
|
||||
|
||||
### Area
|
||||
[ ] Installation instructions
|
||||
[ ] Configuration parameters
|
||||
[ ] Functionality/features
|
||||
[ ] REST API
|
||||
[ ] Administration/development
|
||||
[ ] Other
|
||||
|
||||
<!-- Describe the proposed change(s). -->
|
||||
### Proposed Changes
|
||||
40
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: 📖 Documentation Change
|
||||
about: Suggest an addition or modification to the NetBox documentation
|
||||
labels: ["type: documentation"]
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Change Type
|
||||
description: What type of change are you proposing?
|
||||
options:
|
||||
- Addition
|
||||
- Correction
|
||||
- Removal
|
||||
- Cleanup (formatting, typos, etc.)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Area
|
||||
description: To what section of the documentation does this change primarily pertain?
|
||||
options:
|
||||
- Installation instructions
|
||||
- Configuration parameters
|
||||
- Functionality/features
|
||||
- REST API
|
||||
- Administration/development
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Changes
|
||||
description: "Describe the proposed changes and why they are necessary"
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Additional information
|
||||
You can use the space below to provide any additional information or to attach files.
|
||||
54
.github/ISSUE_TEMPLATE/feature_request.md
vendored
54
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Propose a new NetBox feature or enhancement
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
|
||||
|
||||
This form is only for proposing specific new features or enhancements.
|
||||
If you have a general idea or question, please start a discussion instead:
|
||||
https://github.com/netbox-community/netbox/discussions
|
||||
|
||||
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:
|
||||
* NetBox version:
|
||||
|
||||
<!--
|
||||
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
|
||||
58
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
58
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Propose a new NetBox feature or enhancement
|
||||
labels: ["type: feature"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**NOTE:** This form is only for submitting well-formed proposals to extend or
|
||||
modify NetBox in some way. If you're trying to solve a problem but can't figure out how,
|
||||
or if you still need time to work on the details of a proposed new feature, please start
|
||||
a [discussion](https://github.com/netbox-community/netbox/discussions) instead."
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: "What version of NetBox are you currently running?"
|
||||
placeholder: v2.10.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Feature type
|
||||
options:
|
||||
- Data model extension
|
||||
- New functionality
|
||||
- Change to existing functionality
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed functionality
|
||||
description: "Describe in detail the new feature or behavior you'd like to propose.
|
||||
Include any specific changes to work flows, data models, or the user interface."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Use case
|
||||
description: "Explain how adding this functionality would benefit NetBox users. What
|
||||
need does it address?"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Database changes
|
||||
description: "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.)"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: External dependencies
|
||||
description: "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.)"
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Additional information
|
||||
You can use the space below to provide any additional information or to attach files.
|
||||
16
.github/ISSUE_TEMPLATE/housekeeping.md
vendored
16
.github/ISSUE_TEMPLATE/housekeeping.md
vendored
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: 🏡 Housekeeping
|
||||
about: A change pertaining to the codebase itself (developers only)
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
NOTE: This template is for use by maintainers only. Please do not submit
|
||||
an issue using this template unless you have been specifically asked to
|
||||
do so.
|
||||
-->
|
||||
### Proposed Changes
|
||||
|
||||
|
||||
<!-- Provide justification for the proposed change(s). -->
|
||||
### Justification
|
||||
27
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
Normal file
27
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: 🏡 Housekeeping
|
||||
about: A change pertaining to the codebase itself (developers only)
|
||||
labels: ["type: housekeeping"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**NOTE:** This template is for use by maintainers only. Please do not submit
|
||||
an issue using this template unless you have been specifically asked to do so."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Changes
|
||||
description: "Describe in detail the new feature or behavior you'd like to propose.
|
||||
Include any specific changes to work flows, data models, or the user interface."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Justification
|
||||
description: "Please provide justification for the proposed change(s)."
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Additional information
|
||||
You can use the space below to provide any additional information or to attach files.
|
||||
3
.github/stale.yml
vendored
3
.github/stale.yml
vendored
@@ -1,8 +1,5 @@
|
||||
# Configuration for Stale (https://github.com/apps/stale)
|
||||
|
||||
# Pull requests are exempt from being marked as stale
|
||||
only: issues
|
||||
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 45
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: CI
|
||||
on: push
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -25,7 +25,7 @@ discussions.
|
||||
|
||||
### Slack
|
||||
|
||||
For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
|
||||
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://slack.netbox.dev/).
|
||||
Unfortunately, the Slack channel does not provide long-term retention of chat
|
||||
history, so try to avoid it for any discussions would benefit from being
|
||||
preserved for future reference.
|
||||
@@ -185,11 +185,5 @@ overlooked.
|
||||
sync to review agenda items. This meeting provides opportunity to present and
|
||||
discuss pressing topics. Meetings are held as virtual audio/video conferences.
|
||||
|
||||
* Official channels for communication include:
|
||||
|
||||
* GitHub issues, pull requests, and discussions
|
||||
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
|
||||
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
|
||||
|
||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
||||
removed from the project.
|
||||
|
||||
13
README.md
13
README.md
@@ -7,13 +7,16 @@ to address the needs of network and infrastructure engineers. It is intended to
|
||||
function as a domain-specific source of truth for network operations.
|
||||
|
||||
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
|
||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
|
||||
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
### Discussion
|
||||
|
||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
||||
* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
|
||||
|
||||
### Build Status
|
||||
|
||||
@@ -36,7 +39,7 @@ or join us in the **#netbox** Slack channel on [NetworkToCode](https://networkto
|
||||
|
||||
## Installation
|
||||
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the
|
||||
[latest release](https://github.com/netbox-community/netbox/releases) and
|
||||
run `upgrade.sh`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Caching
|
||||
|
||||
NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
|
||||
NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
|
||||
|
||||
If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database.
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
|
||||
|
||||
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
|
||||
|
||||
Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
|
||||
|
||||
!!! warning
|
||||
Support for Django's native templating logic will be removed in NetBox v2.10.
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
|
||||
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
|
||||
By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
|
||||
|
||||
```
|
||||
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
|
||||
|
||||
@@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
|
||||
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
|
||||
|
||||
!!! warning
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
|
||||
@@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo
|
||||
|
||||
## Writing Reports
|
||||
|
||||
Reports must be saved as files in the [`REPORTS_ROOT`](../../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
|
||||
Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
|
||||
|
||||
!!! warning
|
||||
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
|
||||
@@ -66,7 +66,7 @@ class DeviceConnectionsReport(Report):
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
connected_ports += 1
|
||||
if not power_port.connection_status:
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
device,
|
||||
"Power connection for {} marked as planned".format(power_port.name)
|
||||
|
||||
@@ -68,7 +68,7 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
|
||||
## Webhook Processing
|
||||
|
||||
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under Django RQ > Queues.
|
||||
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.
|
||||
|
||||
A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.
|
||||
|
||||
|
||||
@@ -12,13 +12,16 @@ NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general
|
||||
Use the `pg_dump` utility to export the entire database to a file:
|
||||
|
||||
```no-highlight
|
||||
pg_dump netbox > netbox.sql
|
||||
pg_dump --username netbox --password --host localhost netbox > netbox.sql
|
||||
```
|
||||
|
||||
!!! note
|
||||
You may need to change the username, host, and/or database in the command above to match your installation.
|
||||
|
||||
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
|
||||
|
||||
```no-highlight
|
||||
pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql
|
||||
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
|
||||
```
|
||||
|
||||
### Load an Exported Database
|
||||
@@ -41,7 +44,7 @@ Keep in mind that PostgreSQL user accounts and permissions are not included with
|
||||
If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following:
|
||||
|
||||
```no-highlight
|
||||
pg_dump -s netbox > netbox_schema.sql
|
||||
pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
Default: None
|
||||
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
|
||||
|
||||
```python
|
||||
BASE_PATH = 'netbox/'
|
||||
@@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
Default: 900
|
||||
|
||||
The number of seconds to cache entries will be retained before expiring.
|
||||
The number of seconds that cache entries will be retained before expiring.
|
||||
|
||||
---
|
||||
|
||||
@@ -281,6 +281,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
|
||||
|
||||
---
|
||||
|
||||
## MAPS_URL
|
||||
|
||||
Default: `https://maps.google.com/?q=` (Google Maps)
|
||||
|
||||
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
|
||||
|
||||
---
|
||||
|
||||
## MAX_PAGE_SIZE
|
||||
|
||||
Default: 1000
|
||||
@@ -301,7 +309,7 @@ The file path to the location where media files (such as image attachments) are
|
||||
|
||||
Default: False
|
||||
|
||||
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details.
|
||||
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details.
|
||||
|
||||
---
|
||||
|
||||
@@ -318,7 +326,7 @@ NetBox will use these credentials when authenticating to remote devices via the
|
||||
|
||||
## NAPALM_ARGS
|
||||
|
||||
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
|
||||
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
|
||||
|
||||
```python
|
||||
NAPALM_ARGS = {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
||||
|
||||
!!! note
|
||||
This parameter must always be defined as a list or tuple, even if only value is provided.
|
||||
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
||||
|
||||
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
|
||||
|
||||
@@ -101,7 +101,7 @@ REDIS = {
|
||||
|
||||
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
|
||||
configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from
|
||||
above and the addition of two new keys.
|
||||
above and the addition of three new keys.
|
||||
|
||||
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
|
||||
of the Redis server and port for each sentinel instance to connect to
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need:
|
||||
|
||||
* A Linux system or environment
|
||||
* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/)
|
||||
* A Redis server, which can also be [installed locally](/installation/2-redis/)
|
||||
* A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md)
|
||||
* A Redis server, which can also be [installed locally](../installation/2-redis.md)
|
||||
* A supported version of Python
|
||||
|
||||
### Fork the Repo
|
||||
@@ -27,13 +27,13 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ
|
||||
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
|
||||
```
|
||||
|
||||
The NetBox project utilizes three long-term branches:
|
||||
The NetBox project utilizes three persistent git branches to track work:
|
||||
|
||||
* `master` - Serves as a snapshot of the current stable release
|
||||
* `develop` - All development on the upcoming stable release occurs here
|
||||
* `develop-x.y` - Tracks work on an upcoming major release
|
||||
* `feature` - Tracks work on an upcoming major release
|
||||
|
||||
Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
|
||||
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
|
||||
|
||||
### Enable Pre-Commit Hooks
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
|
||||
## Communication
|
||||
|
||||
Communication among developers should always occur via public channels:
|
||||
There are several official forums for communication among the developers and community members:
|
||||
|
||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
|
||||
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
|
||||
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [#netbox on NetDev Community Slack](https://slack.netbox.dev/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
|
||||
|
||||
## Governance
|
||||
|
||||
|
||||
@@ -2,24 +2,9 @@
|
||||
|
||||
## Minor Version Bumps
|
||||
|
||||
### Update Requirements
|
||||
### Address Pinned Dependencies
|
||||
|
||||
Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example:
|
||||
|
||||
```
|
||||
# https://github.com/encode/django-rest-framework/issues/6053
|
||||
djangorestframework==3.8.1
|
||||
```
|
||||
|
||||
The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox.
|
||||
|
||||
Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
|
||||
|
||||
1. Create a new virtual environment.
|
||||
2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
|
||||
3. Run all tests and check that the UI and API function as expected.
|
||||
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
|
||||
5. Update the package versions in `requirements.txt` as appropriate.
|
||||
Check `base_requirements.txt` for any dependencies pinned to a specific version, and upgrade them to their most stable release (where possible).
|
||||
|
||||
### Update Static Libraries
|
||||
|
||||
@@ -52,15 +37,33 @@ Close the release milestone on GitHub after ensuring there are no remaining open
|
||||
|
||||
### Merge the Release Branch
|
||||
|
||||
Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
|
||||
|
||||
!!! warning
|
||||
No further releases for the current major version can be published once this pull request is merged.
|
||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
|
||||
|
||||
---
|
||||
|
||||
## All Releases
|
||||
|
||||
### Update Requirements
|
||||
|
||||
Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example:
|
||||
|
||||
```
|
||||
# https://github.com/encode/django-rest-framework/issues/6053
|
||||
djangorestframework==3.8.1
|
||||
```
|
||||
|
||||
The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox.
|
||||
|
||||
Every release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
|
||||
|
||||
1. Create a new virtual environment.
|
||||
2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
|
||||
3. Run all tests and check that the UI and API function as expected.
|
||||
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
|
||||
5. Update the package versions in `requirements.txt` as appropriate.
|
||||
|
||||
In cases where upgrading a dependency to its most recent release is breaking, it should be pinned to its current minor version in `base_requirements.txt` (with an explanatory comment) and revisited for the next major NetBox release.
|
||||
|
||||
### Verify CI Build Status
|
||||
|
||||
Ensure that continuous integration testing on the `develop` branch is completing successfully.
|
||||
|
||||
@@ -7,12 +7,12 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
!!! note
|
||||
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
|
||||
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
### CentOS
|
||||
@@ -83,7 +83,7 @@ Checking connectivity... done.
|
||||
```
|
||||
|
||||
!!! note
|
||||
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
|
||||
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
|
||||
|
||||
## Create the NetBox System User
|
||||
|
||||
@@ -113,7 +113,7 @@ cd /opt/netbox/netbox/netbox/
|
||||
sudo cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
|
||||
|
||||
* `ALLOWED_HOSTS`
|
||||
* `DATABASE`
|
||||
@@ -136,7 +136,7 @@ ALLOWED_HOSTS = ['*']
|
||||
|
||||
### DATABASE
|
||||
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters.
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters.
|
||||
|
||||
```python
|
||||
DATABASE = {
|
||||
@@ -151,7 +151,7 @@ DATABASE = {
|
||||
|
||||
### REDIS
|
||||
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters.
|
||||
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
|
||||
|
||||
@@ -203,7 +203,7 @@ sudo echo napalm >> /opt/netbox/local_requirements.txt
|
||||
|
||||
### Remote File Storage
|
||||
|
||||
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
|
||||
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
sudo echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HTTP Server Setup
|
||||
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
@@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/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/).
|
||||
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/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](https://django-auth-ldap.readthedocs.io/).
|
||||
|
||||
### General Server Configuration
|
||||
|
||||
@@ -140,9 +140,9 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
|
||||
For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`:
|
||||
|
||||
```python
|
||||
LOGGING = {
|
||||
|
||||
@@ -11,6 +11,10 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Review the Release Notes
|
||||
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
|
||||
|
||||
## Update Dependencies to Required Versions
|
||||
|
||||
|
||||
@@ -63,11 +63,15 @@ setup(
|
||||
install_requires=[],
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
|
||||
|
||||
!!! note
|
||||
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
|
||||
|
||||
### Define a PluginConfig
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
||||
@@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
|
||||
|
||||
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
|
||||
|
||||
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
|
||||
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
||||
@@ -1,5 +1,162 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10.8 (2021-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6060](https://github.com/netbox-community/netbox/issues/6060) - Fix exception on cable trace in UI (regression from #5650)
|
||||
|
||||
---
|
||||
|
||||
## v2.10.7 (2021-03-25)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5641](https://github.com/netbox-community/netbox/issues/5641) - Allow filtering device components by label
|
||||
* [#5723](https://github.com/netbox-community/netbox/issues/5723) - Allow customization of the geographic mapping service via `MAPS_URL` config parameter
|
||||
* [#5736](https://github.com/netbox-community/netbox/issues/5736) - Allow changing site assignment when bulk editing devices
|
||||
* [#5953](https://github.com/netbox-community/netbox/issues/5953) - Support Markdown rendering for custom script descriptions
|
||||
* [#6040](https://github.com/netbox-community/netbox/issues/6040) - Add UI search fields for asset tag for devices and racks
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5595](https://github.com/netbox-community/netbox/issues/5595) - Restore ability to delete an uploaded device type image
|
||||
* [#5650](https://github.com/netbox-community/netbox/issues/5650) - Denote when the total length of a cable trace may exceed the indicated value
|
||||
* [#5962](https://github.com/netbox-community/netbox/issues/5962) - Ensure consistent display of change log action labels
|
||||
* [#5966](https://github.com/netbox-community/netbox/issues/5966) - Skip Markdown reference link when tabbing through form fields
|
||||
* [#5977](https://github.com/netbox-community/netbox/issues/5977) - Correct validation of `RELEASE_CHECK_URL` config parameter
|
||||
* [#6006](https://github.com/netbox-community/netbox/issues/6006) - Fix VLAN group/site association for bulk prefix import
|
||||
* [#6010](https://github.com/netbox-community/netbox/issues/6010) - Eliminate duplicate virtual chassis search results
|
||||
* [#6012](https://github.com/netbox-community/netbox/issues/6012) - Pre-populate attributes when creating an available child prefix via the UI
|
||||
* [#6023](https://github.com/netbox-community/netbox/issues/6023) - Fix display of bottom banner with uBlock Origin enabled
|
||||
|
||||
---
|
||||
|
||||
## v2.10.6 (2021-03-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5592](https://github.com/netbox-community/netbox/issues/5592) - Add IP addresses count to VRF view
|
||||
* [#5630](https://github.com/netbox-community/netbox/issues/5630) - Add QSFP+ (64GFC) FibreChannel Interface option
|
||||
* [#5884](https://github.com/netbox-community/netbox/issues/5884) - Enable custom links for device components
|
||||
* [#5914](https://github.com/netbox-community/netbox/issues/5914) - Add edit/delete buttons for IP addresses on interface view
|
||||
* [#5942](https://github.com/netbox-community/netbox/issues/5942) - Add button to add a new IP address on interface view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5703](https://github.com/netbox-community/netbox/issues/5703) - Fix VRF and Tenant field population when adding IP addresses from prefix
|
||||
* [#5819](https://github.com/netbox-community/netbox/issues/5819) - Enable ordering of virtual machines by primary IP address
|
||||
* [#5872](https://github.com/netbox-community/netbox/issues/5872) - Ordering of devices by primary IP should respect `PREFER_IPV4` configuration parameter
|
||||
* [#5922](https://github.com/netbox-community/netbox/issues/5922) - Fix options for filtering object permissions in admin UI
|
||||
* [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values
|
||||
* [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize`
|
||||
|
||||
---
|
||||
|
||||
## v2.10.5 (2021-02-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5315](https://github.com/netbox-community/netbox/issues/5315) - Fix site unassignment from VLAN when using "None" option
|
||||
* [#5626](https://github.com/netbox-community/netbox/issues/5626) - Fix REST API representation for circuit terminations connected to non-interface endpoints
|
||||
* [#5716](https://github.com/netbox-community/netbox/issues/5716) - Fix filtering rack reservations by custom field
|
||||
* [#5718](https://github.com/netbox-community/netbox/issues/5718) - Fix bulk editing of services when no port(s) are defined
|
||||
* [#5735](https://github.com/netbox-community/netbox/issues/5735) - Ensure consistent treatment of duplicate IP addresses
|
||||
* [#5738](https://github.com/netbox-community/netbox/issues/5738) - Fix redirect to device components view after disconnecting a cable
|
||||
* [#5753](https://github.com/netbox-community/netbox/issues/5753) - Fix Redis Sentinel password application for caching
|
||||
* [#5786](https://github.com/netbox-community/netbox/issues/5786) - Allow setting null tenant group on tenant via REST API
|
||||
* [#5841](https://github.com/netbox-community/netbox/issues/5841) - Disallow the creation of available prefixes/IP addresses in violation of assigned permission constraints
|
||||
|
||||
---
|
||||
|
||||
## v2.10.4 (2021-01-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5542](https://github.com/netbox-community/netbox/issues/5542) - Show cable trace lengths in both meters and feet
|
||||
* [#5570](https://github.com/netbox-community/netbox/issues/5570) - Add "management only" filter widget for interfaces list
|
||||
* [#5586](https://github.com/netbox-community/netbox/issues/5586) - Allow filtering virtual chassis by name and master
|
||||
* [#5612](https://github.com/netbox-community/netbox/issues/5612) - Add GG45 and TERA port types, and CAT7a and CAT8 cable types
|
||||
* [#5678](https://github.com/netbox-community/netbox/issues/5678) - Show available type choices for all device component import forms
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5232](https://github.com/netbox-community/netbox/issues/5232) - Correct swagger definition for ip_prefixes_available-ips_create API
|
||||
* [#5574](https://github.com/netbox-community/netbox/issues/5574) - Restrict the creation of device bay templates on non-parent device types
|
||||
* [#5584](https://github.com/netbox-community/netbox/issues/5584) - Restore power utilization panel under device view
|
||||
* [#5597](https://github.com/netbox-community/netbox/issues/5597) - Fix ordering devices by primary IP address
|
||||
* [#5603](https://github.com/netbox-community/netbox/issues/5603) - Fix display of white cables in trace view
|
||||
* [#5639](https://github.com/netbox-community/netbox/issues/5639) - Fix filtering connection lists by device name
|
||||
* [#5640](https://github.com/netbox-community/netbox/issues/5640) - Fix permissions assessment when adding VM interfaces in bulk
|
||||
* [#5648](https://github.com/netbox-community/netbox/issues/5648) - Include VC member interfaces on interfaces tab count when viewing VC master
|
||||
* [#5665](https://github.com/netbox-community/netbox/issues/5665) - Validate rack group is assigned to same site when creating a rack
|
||||
* [#5683](https://github.com/netbox-community/netbox/issues/5683) - Correct rack elevation displayed when viewing a reservation
|
||||
|
||||
---
|
||||
|
||||
## v2.10.3 (2021-01-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors
|
||||
* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
|
||||
* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
|
||||
* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
|
||||
* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields
|
||||
* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned
|
||||
* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices
|
||||
* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list
|
||||
* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API
|
||||
* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields
|
||||
* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link
|
||||
* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw`
|
||||
* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field
|
||||
* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data
|
||||
|
||||
---
|
||||
|
||||
## v2.10.2 (2020-12-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list
|
||||
* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
|
||||
* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
|
||||
* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
|
||||
* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
|
||||
* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
|
||||
* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
|
||||
* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
|
||||
* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
|
||||
* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
|
||||
* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
|
||||
* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
|
||||
* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
|
||||
|
||||
---
|
||||
|
||||
## v2.10.1 (2020-12-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
|
||||
* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
|
||||
* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
|
||||
* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
|
||||
* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
|
||||
* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
|
||||
* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
|
||||
* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
|
||||
* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
|
||||
|
||||
---
|
||||
|
||||
## v2.10.0 (2020-12-14)
|
||||
|
||||
**NOTE:** This release completely removes support for embedded graphs.
|
||||
|
||||
@@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
|
||||
|
||||
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
|
||||
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ http://netbox/api/dcim/sites/
|
||||
}
|
||||
```
|
||||
|
||||
A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
|
||||
A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
|
||||
|
||||
```
|
||||
$ curl http://netbox/api/dcim/sites/
|
||||
|
||||
@@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
|
||||
- `nisw` - negated case insensitive starts with
|
||||
- `iew` - case insensitive ends with
|
||||
- `niew` - negated case insensitive ends with
|
||||
- `ie` - case sensitive exact match
|
||||
- `nie` - negated case sensitive exact match
|
||||
- `ie` - case insensitive exact match
|
||||
- `nie` - negated case insensitive exact match
|
||||
|
||||
### Foreign Keys & Other Fields
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## What is a REST API?
|
||||
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
|
||||
* `GET`: Retrieve an object or list of objects
|
||||
* `POST`: Create an object
|
||||
@@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects.
|
||||
|
||||
### Excluding Config Contexts
|
||||
|
||||
When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
|
||||
When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
|
||||
|
||||
## Pagination
|
||||
|
||||
@@ -308,7 +308,7 @@ Vary: Accept
|
||||
}
|
||||
```
|
||||
|
||||
The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
|
||||
The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
|
||||
|
||||
```
|
||||
http://netbox/api/dcim/devices/?limit=100
|
||||
@@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a
|
||||
}
|
||||
```
|
||||
|
||||
The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
|
||||
The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
|
||||
|
||||
!!! 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.
|
||||
@@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
|
||||
|
||||
### Creating a New Object
|
||||
|
||||
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
|
||||
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/index.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
|
||||
|
||||
```no-highlight
|
||||
curl -s -X POST \
|
||||
|
||||
@@ -4,7 +4,7 @@ As with most other objects, the REST API can be used to view, create, modify, an
|
||||
|
||||
## Generating a Session Key
|
||||
|
||||
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`.
|
||||
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`.
|
||||
|
||||
```no-highlight
|
||||
$ curl -X POST http://netbox/api/secrets/get-session-key/ \
|
||||
|
||||
@@ -40,14 +40,16 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count']
|
||||
|
||||
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
site = NestedSiteSerializer()
|
||||
connected_endpoint = NestedInterfaceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
|
||||
fields = [
|
||||
'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable',
|
||||
]
|
||||
|
||||
|
||||
class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
|
||||
@@ -6,7 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from dcim.api.views import PathEndpointMixin
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class CircuitsRootView(APIRootView):
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=get_subquery(Circuit, 'provider')
|
||||
circuit_count=count_related(Circuit, 'provider')
|
||||
)
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
@@ -36,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilterSet
|
||||
@@ -65,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filters.CircuitTerminationFilterSet
|
||||
brief_prefetch_fields = ['circuit']
|
||||
|
||||
@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
@@ -67,7 +67,7 @@ class ProviderBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ProviderBulkEditView(generic.BulkEditView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
@@ -76,7 +76,7 @@ class ProviderBulkEditView(generic.BulkEditView):
|
||||
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
@@ -88,7 +88,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class CircuitTypeListView(generic.ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
@@ -110,7 +110,7 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
@@ -139,7 +139,7 @@ class CircuitView(generic.ObjectView):
|
||||
).filter(
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
if termination_a and termination_a.connected_endpoint:
|
||||
if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
|
||||
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
# Z-side termination
|
||||
@@ -148,7 +148,7 @@ class CircuitView(generic.ObjectView):
|
||||
).filter(
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
if termination_z and termination_z.connected_endpoint:
|
||||
if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
|
||||
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
@@ -13,13 +14,12 @@ from dcim.models import (
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from dcim.utils import decompile_path_node
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
|
||||
from ipam.models import VLAN
|
||||
from netbox.api import (
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
)
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
@@ -98,7 +98,7 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
status = ChoiceField(choices=SiteStatusChoices, required=False)
|
||||
region = NestedRegionSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneField(required=False)
|
||||
time_zone = TimeZoneSerializerField(required=False)
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -119,12 +119,12 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.prefetch_related(
|
||||
'region', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'site'),
|
||||
rack_count=get_subquery(Rack, 'site'),
|
||||
prefix_count=get_subquery(Prefix, 'site'),
|
||||
vlan_count=get_subquery(VLAN, 'site'),
|
||||
circuit_count=get_subquery(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
|
||||
device_count=count_related(Device, 'site'),
|
||||
rack_count=count_related(Rack, 'site'),
|
||||
prefix_count=count_related(Prefix, 'site'),
|
||||
vlan_count=count_related(VLAN, 'site'),
|
||||
circuit_count=count_related(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
|
||||
)
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilterSet
|
||||
@@ -152,7 +152,7 @@ class RackGroupViewSet(ModelViewSet):
|
||||
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=get_subquery(Rack, 'role')
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilterSet
|
||||
@@ -166,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group__site', 'role', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack'),
|
||||
powerfeed_count=get_subquery(PowerFeed, 'rack')
|
||||
device_count=count_related(Device, 'rack'),
|
||||
powerfeed_count=count_related(PowerFeed, 'rack')
|
||||
)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilterSet
|
||||
@@ -240,9 +240,9 @@ class RackReservationViewSet(ModelViewSet):
|
||||
|
||||
class ManufacturerViewSet(ModelViewSet):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filters.ManufacturerFilterSet
|
||||
@@ -254,10 +254,11 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
|
||||
device_count=get_subquery(Device, 'device_type')
|
||||
device_count=count_related(Device, 'device_type')
|
||||
)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
brief_prefetch_fields = ['manufacturer']
|
||||
|
||||
|
||||
#
|
||||
@@ -318,8 +319,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'role')
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilterSet
|
||||
@@ -331,8 +332,8 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filters.PlatformFilterSet
|
||||
@@ -342,7 +343,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@@ -493,6 +494,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsolePortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
@@ -501,18 +503,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filterset_class = filters.ConsoleServerPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filterset_class = filters.PowerOutletFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
@@ -521,30 +526,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filters.InterfaceFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filters.FrontPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filters.RearPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
filterset_class = filters.DeviceBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filters.InventoryItemFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
#
|
||||
@@ -596,10 +606,11 @@ class CableViewSet(ModelViewSet):
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=get_subquery(Device, 'virtual_chassis')
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
brief_prefetch_fields = ['master']
|
||||
|
||||
|
||||
#
|
||||
@@ -610,7 +621,7 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilterSet
|
||||
|
||||
@@ -686,6 +686,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
|
||||
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
|
||||
TYPE_32GFC_SFP28 = '32gfc-sfp28'
|
||||
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
|
||||
TYPE_128GFC_QSFP28 = '128gfc-sfp28'
|
||||
|
||||
# InfiniBand
|
||||
@@ -801,6 +802,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
|
||||
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
|
||||
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
|
||||
(TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
|
||||
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
|
||||
)
|
||||
),
|
||||
@@ -873,6 +875,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_GG45 = 'gg45'
|
||||
TYPE_TERA4P = 'tera-4p'
|
||||
TYPE_TERA2P = 'tera-2p'
|
||||
TYPE_TERA1P = 'tera-1p'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
@@ -898,6 +904,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_GG45, 'GG45'),
|
||||
(TYPE_TERA4P, 'TERA 4P'),
|
||||
(TYPE_TERA2P, 'TERA 2P'),
|
||||
(TYPE_TERA1P, 'TERA 1P'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
@@ -936,6 +946,8 @@ class CableTypeChoices(ChoiceSet):
|
||||
TYPE_CAT6 = 'cat6'
|
||||
TYPE_CAT6A = 'cat6a'
|
||||
TYPE_CAT7 = 'cat7'
|
||||
TYPE_CAT7A = 'cat7a'
|
||||
TYPE_CAT8 = 'cat8'
|
||||
TYPE_DAC_ACTIVE = 'dac-active'
|
||||
TYPE_DAC_PASSIVE = 'dac-passive'
|
||||
TYPE_MRJ21_TRUNK = 'mrj21-trunk'
|
||||
@@ -960,6 +972,8 @@ class CableTypeChoices(ChoiceSet):
|
||||
(TYPE_CAT6, 'CAT6'),
|
||||
(TYPE_CAT6A, 'CAT6a'),
|
||||
(TYPE_CAT7, 'CAT7'),
|
||||
(TYPE_CAT7A, 'CAT7a'),
|
||||
(TYPE_CAT8, 'CAT8'),
|
||||
(TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
|
||||
(TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
|
||||
(TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'),
|
||||
|
||||
@@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
choices=RackStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=RackTypeChoices
|
||||
)
|
||||
width = django_filters.MultipleChoiceFilter(
|
||||
choices=RackWidthChoices
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
@@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
|
||||
'outer_width', 'outer_depth', 'outer_unit',
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -258,7 +264,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
)
|
||||
|
||||
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
label='User (ID)',
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user',
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label='User (name)',
|
||||
@@ -777,7 +783,7 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'name', 'description']
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class ConsoleServerPortFilterSet(
|
||||
@@ -793,7 +799,7 @@ class ConsoleServerPortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'name', 'description']
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
@@ -804,7 +810,7 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description']
|
||||
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
|
||||
|
||||
|
||||
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
@@ -815,7 +821,7 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'name', 'feed_leg', 'description']
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description']
|
||||
|
||||
|
||||
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
@@ -861,7 +867,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
|
||||
fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
@@ -915,21 +921,21 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['id', 'name', 'type', 'description']
|
||||
fields = ['id', 'name', 'label', 'type', 'description']
|
||||
|
||||
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'name', 'type', 'positions', 'description']
|
||||
fields = ['id', 'name', 'label', 'type', 'positions', 'description']
|
||||
|
||||
|
||||
class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'name', 'description']
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
@@ -990,7 +996,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered']
|
||||
fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1010,6 +1016,16 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
master_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Master (ID)',
|
||||
)
|
||||
master = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='master__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Master (name)',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='master__site__region',
|
||||
@@ -1049,7 +1065,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'domain']
|
||||
fields = ['id', 'domain', 'name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1059,7 +1075,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
Q(members__name__icontains=value) |
|
||||
Q(domain__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class CableFilterSet(BaseFilterSet):
|
||||
@@ -1136,7 +1152,7 @@ class ConnectionFilterSet:
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(device_id__in=value)
|
||||
return queryset.filter(**{f'{name}__in': value})
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
|
||||
@@ -21,7 +21,7 @@ from ipam.models import IPAddress, VLAN
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
@@ -60,12 +60,18 @@ def get_device_by_name_or_pk(name):
|
||||
|
||||
class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
|
||||
field_order = [
|
||||
'q', 'region', 'site'
|
||||
'q', 'name', 'label', 'region', 'site'
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
label = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -134,6 +140,7 @@ class ComponentForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
|
||||
if self.cleaned_data['label_pattern']:
|
||||
@@ -658,7 +665,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
|
||||
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = Rack
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant', 'asset_tag']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
@@ -690,12 +697,25 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=RackTypeChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
width = forms.MultipleChoiceField(
|
||||
choices=RackWidthChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
role = DynamicModelMultipleChoiceField(
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
null_option='None'
|
||||
)
|
||||
asset_tag = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -773,7 +793,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class RackReservationCSVForm(CSVModelForm):
|
||||
class RackReservationCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -823,7 +843,7 @@ class RackReservationCSVForm(CSVModelForm):
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RackReservation.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -850,7 +870,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
@@ -874,6 +894,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
label='Rack group',
|
||||
null_option='None'
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
display_field='username',
|
||||
label='User',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -924,10 +953,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
widgets = {
|
||||
'subdevice_role': StaticSelect2(),
|
||||
# Exclude SVG images (unsupported by PIL)
|
||||
'front_image': forms.FileInput(attrs={
|
||||
'front_image': forms.ClearableFileInput(attrs={
|
||||
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
|
||||
}),
|
||||
'rear_image': forms.FileInput(attrs={
|
||||
'rear_image': forms.ClearableFileInput(attrs={
|
||||
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
|
||||
})
|
||||
}
|
||||
@@ -1419,6 +1448,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
|
||||
front_port_count = len(self.cleaned_data['name_pattern'])
|
||||
@@ -1762,9 +1792,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'group_id': '$rack_group',
|
||||
}
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
position = forms.IntegerField(
|
||||
required=False,
|
||||
empty_value=None,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
@@ -1837,6 +1866,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
"config context",
|
||||
}
|
||||
widgets = {
|
||||
'face': StaticSelect2(),
|
||||
'status': StaticSelect2(),
|
||||
'primary_ip4': StaticSelect2(),
|
||||
'primary_ip6': StaticSelect2(),
|
||||
@@ -1883,6 +1913,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
@@ -1892,31 +1929,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Rack position
|
||||
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']) \
|
||||
.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']) \
|
||||
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||
else:
|
||||
position_choices = []
|
||||
except Rack.DoesNotExist:
|
||||
position_choices = []
|
||||
self.fields['position'].choices = [('', '---------')] + [
|
||||
(p['id'], {
|
||||
'label': p['name'],
|
||||
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
|
||||
}) for p in position_choices
|
||||
]
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
position = self.data.get('position') or self.initial.get('position')
|
||||
if position:
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
|
||||
@@ -2082,6 +2097,10 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
@@ -2111,7 +2130,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
model = Device
|
||||
field_order = [
|
||||
'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
|
||||
'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip',
|
||||
'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -2179,6 +2198,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
asset_tag = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC address'
|
||||
@@ -2346,6 +2368,11 @@ class ConsolePortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
@@ -2419,6 +2446,11 @@ class ConsoleServerPortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
@@ -2504,6 +2536,11 @@ class PowerPortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
@@ -2624,6 +2661,11 @@ class PowerOutletCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False,
|
||||
help_text='Outlet type'
|
||||
)
|
||||
power_port = CSVModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False,
|
||||
@@ -2681,6 +2723,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC address'
|
||||
@@ -2839,7 +2887,7 @@ class InterfaceBulkCreateForm(
|
||||
|
||||
class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
|
||||
'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
|
||||
]),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
@@ -2855,6 +2903,15 @@ class InterfaceBulkEditForm(
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
label='Management only'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@@ -2916,6 +2973,7 @@ class InterfaceBulkEditForm(
|
||||
self.fields['lag'].widget.attrs['disabled'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
@@ -3064,6 +3122,7 @@ class FrontPortCreateForm(ComponentCreateForm):
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
|
||||
front_port_count = len(self.cleaned_data['name_pattern'])
|
||||
@@ -3758,7 +3817,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class CableCSVForm(CSVModelForm):
|
||||
class CableCSVForm(CustomFieldModelCSVForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -3853,7 +3912,7 @@ class CableCSVForm(CSVModelForm):
|
||||
return length_unit if length_unit is not None else ''
|
||||
|
||||
|
||||
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Cable.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -3896,6 +3955,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate length/unit
|
||||
length = self.cleaned_data.get('length')
|
||||
@@ -4239,7 +4299,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
return device
|
||||
|
||||
|
||||
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -4253,7 +4313,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
|
||||
nullable_fields = ['domain']
|
||||
|
||||
|
||||
class VirtualChassisCSVForm(CSVModelForm):
|
||||
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
|
||||
master = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -4340,7 +4400,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class PowerPanelCSVForm(CSVModelForm):
|
||||
class PowerPanelCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -4366,7 +4426,7 @@ class PowerPanelCSVForm(CSVModelForm):
|
||||
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
|
||||
|
||||
|
||||
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -4394,9 +4454,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
'rack_group',
|
||||
)
|
||||
nullable_fields = ['rack_group']
|
||||
|
||||
|
||||
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
|
||||
@@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
pk = self.pk or self._pk
|
||||
return self.label or f'#{pk}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
@@ -479,17 +480,23 @@ class CablePath(models.Model):
|
||||
|
||||
def get_total_length(self):
|
||||
"""
|
||||
Return the sum of the length of each cable in the path.
|
||||
Return a tuple containing the sum of the length of each cable in the path
|
||||
and a flag indicating whether the length is definitive.
|
||||
"""
|
||||
cable_ids = [
|
||||
# Starting from the first element, every third element in the path should be a Cable
|
||||
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
|
||||
]
|
||||
return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total']
|
||||
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
|
||||
total_length = cables.aggregate(total=Sum('_abs_length'))['total']
|
||||
is_definitive = len(cables) == len(cable_ids)
|
||||
|
||||
return total_length, is_definitive
|
||||
|
||||
def get_split_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
rearport = path_node_to_object(self.path[-1])
|
||||
|
||||
return FrontPort.objects.filter(rear_port=rearport)
|
||||
|
||||
@@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
|
||||
allocated_draw=self.allocated_draw
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
raise ValidationError({
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
|
||||
class PowerOutletTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device_type != self.device_type:
|
||||
@@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device_type != self.device_type:
|
||||
@@ -352,3 +363,9 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
name=self.name,
|
||||
label=self.label
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
||||
raise ValidationError(
|
||||
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
|
||||
)
|
||||
|
||||
@@ -198,7 +198,7 @@ class PathEndpoint(models.Model):
|
||||
# Console ports
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
@extras_features('export_templates', 'webhooks', 'custom_links')
|
||||
class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
@@ -234,7 +234,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
# Console server ports
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'custom_links')
|
||||
class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
@@ -270,7 +270,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
# Power ports
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
@extras_features('export_templates', 'webhooks', 'custom_links')
|
||||
class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
@@ -316,6 +316,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
@@ -378,7 +379,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
# Power outlets
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'custom_links')
|
||||
class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
@@ -425,6 +426,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device != self.device:
|
||||
@@ -477,7 +479,7 @@ class BaseInterface(models.Model):
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
@extras_features('export_templates', 'webhooks', 'custom_links')
|
||||
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
"""
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
@@ -503,7 +505,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
)
|
||||
mgmt_only = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='OOB Management',
|
||||
verbose_name='Management only',
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
@@ -555,6 +557,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and (
|
||||
@@ -621,7 +624,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'custom_links')
|
||||
class FrontPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
@@ -668,6 +671,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
@@ -683,7 +687,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
})
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'custom_links')
|
||||
class RearPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
@@ -711,6 +715,7 @@ class RearPort(CableTermination, ComponentModel):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that positions count is greater than or equal to the number of associated FrontPorts
|
||||
frontport_count = self.frontports.count()
|
||||
@@ -735,7 +740,7 @@ class RearPort(CableTermination, ComponentModel):
|
||||
# Device bays
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'custom_links')
|
||||
class DeviceBay(ComponentModel):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
@@ -768,6 +773,7 @@ class DeviceBay(ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
@@ -794,7 +800,7 @@ class DeviceBay(ComponentModel):
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
@extras_features('export_templates', 'webhooks', 'custom_links')
|
||||
class InventoryItem(MPTTModel, ComponentModel):
|
||||
"""
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
|
||||
@@ -640,7 +640,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Validate site/rack combination
|
||||
if self.rack and self.site != self.rack.site:
|
||||
raise ValidationError({
|
||||
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
|
||||
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
|
||||
})
|
||||
|
||||
if self.rack is None:
|
||||
@@ -650,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
})
|
||||
if self.position:
|
||||
raise ValidationError({
|
||||
'face': "Cannot select a rack position without assigning a rack.",
|
||||
'position': "Cannot select a rack position without assigning a rack.",
|
||||
})
|
||||
|
||||
# Validate position/face combination
|
||||
@@ -662,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# 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)
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
|
||||
if self.rack:
|
||||
@@ -688,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
if self.position and self.position not in available_units:
|
||||
raise ValidationError({
|
||||
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
|
||||
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
|
||||
'position': f"U{self.position} is already occupied or does not have sufficient space to "
|
||||
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
|
||||
})
|
||||
|
||||
except DeviceType.DoesNotExist:
|
||||
|
||||
@@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Parent RackGroup (if any) must belong to the same Site
|
||||
if self.parent and self.parent.site != self.site:
|
||||
@@ -298,6 +299,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate group/site assignment
|
||||
if self.site and self.group and self.group.site != self.site:
|
||||
raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
@@ -326,22 +331,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
'group': "Rack group must be from the same site, {}.".format(self.site)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Record the original site assignment for this rack.
|
||||
_site_id = None
|
||||
if self.pk:
|
||||
_site_id = Rack.objects.get(pk=self.pk).site_id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update racked devices if the assigned Site has been changed.
|
||||
if _site_id is not None and self.site_id != _site_id:
|
||||
devices = Device.objects.filter(rack=self)
|
||||
for device in devices:
|
||||
device.site = self.site
|
||||
device.save()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site.name,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
|
||||
from cacheops import invalidate_obj
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableStatusChoices
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
|
||||
|
||||
|
||||
def create_cablepath(node):
|
||||
@@ -30,10 +31,48 @@ def rebuild_paths(obj):
|
||||
|
||||
with transaction.atomic():
|
||||
for cp in cable_paths:
|
||||
invalidate_obj(cp.origin)
|
||||
cp.delete()
|
||||
create_cablepath(cp.origin)
|
||||
|
||||
|
||||
#
|
||||
# Site/rack/device assignment
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=RackGroup)
|
||||
def handle_rackgroup_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
|
||||
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
|
||||
"""
|
||||
if not created:
|
||||
for rackgroup in instance.get_children():
|
||||
rackgroup.site = instance.site
|
||||
rackgroup.save()
|
||||
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
|
||||
rack.site = instance.site
|
||||
rack.save()
|
||||
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
|
||||
powerpanel.site = instance.site
|
||||
powerpanel.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
def handle_rack_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child Devices if Site assignment has changed.
|
||||
"""
|
||||
if not created:
|
||||
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
|
||||
device.site = instance.site
|
||||
device.save()
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||
"""
|
||||
@@ -58,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
|
||||
device.save()
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
|
||||
@receiver(post_save, sender=Cable)
|
||||
def update_connected_endpoints(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
from django.conf import settings
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
|
||||
@@ -127,10 +128,18 @@ class DeviceTable(BaseTable):
|
||||
verbose_name='Type',
|
||||
text=lambda record: record.device_type.display_name
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
if settings.PREFER_IPV4:
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
else:
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip6', 'primary_ip4'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip4 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv4 Address'
|
||||
@@ -406,6 +415,7 @@ class BaseInterfaceTable(BaseTable):
|
||||
|
||||
|
||||
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
|
||||
mgmt_only = BooleanColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
@@ -447,7 +457,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else '',
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
|
||||
@@ -19,7 +19,8 @@ class RegionTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
site_count = tables.Column(
|
||||
verbose_name='Sites'
|
||||
|
||||
@@ -57,13 +57,10 @@ INTERFACE_TAGGED_VLANS = """
|
||||
"""
|
||||
|
||||
MPTT_LINK = """
|
||||
{% if record.get_children %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
|
||||
{% else %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}9px">
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
</span>
|
||||
{% for i in record.get_ancestors %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
"""
|
||||
|
||||
POWERFEED_CABLE = """
|
||||
@@ -98,6 +95,11 @@ CONSOLEPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -118,6 +120,11 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -138,6 +145,11 @@ POWERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -157,6 +169,11 @@ POWEROUTLET_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -175,6 +192,11 @@ INTERFACE_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.is_connectable and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -196,6 +218,11 @@ FRONTPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -219,6 +246,11 @@ REARPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
|
||||
@@ -740,7 +740,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
manufacturer=manufacturer,
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
|
||||
)
|
||||
|
||||
device_bay_templates = (
|
||||
|
||||
@@ -329,7 +329,7 @@ class RackTestCase(TestCase):
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
@@ -351,13 +351,11 @@ class RackTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': RackTypeChoices.TYPE_2POST}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_width(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'width': RackWidthChoices.WIDTH_19IN}
|
||||
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_u_height(self):
|
||||
@@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
|
||||
users = User.objects.all()[:2]
|
||||
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
# TODO: Filtering by username is broken
|
||||
# params = {'user': [users[0].username, users[1].username]}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'user': [users[0].username, users[1].username]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
@@ -1491,9 +1488,9 @@ class ConsolePortTestCase(TestCase):
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
console_ports = (
|
||||
ConsolePort(device=devices[0], name='Console Port 1', description='First'),
|
||||
ConsolePort(device=devices[1], name='Console Port 2', description='Second'),
|
||||
ConsolePort(device=devices[2], name='Console Port 3', description='Third'),
|
||||
ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'),
|
||||
ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'),
|
||||
ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'),
|
||||
)
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
@@ -1510,6 +1507,10 @@ class ConsolePortTestCase(TestCase):
|
||||
params = {'name': ['Console Port 1', 'Console Port 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1587,9 +1588,9 @@ class ConsoleServerPortTestCase(TestCase):
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(device=devices[0], name='Console Server Port 1', description='First'),
|
||||
ConsoleServerPort(device=devices[1], name='Console Server Port 2', description='Second'),
|
||||
ConsoleServerPort(device=devices[2], name='Console Server Port 3', description='Third'),
|
||||
ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'),
|
||||
ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'),
|
||||
ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'),
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
@@ -1606,6 +1607,10 @@ class ConsoleServerPortTestCase(TestCase):
|
||||
params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1683,9 +1688,9 @@ class PowerPortTestCase(TestCase):
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
|
||||
power_ports = (
|
||||
PowerPort(device=devices[0], name='Power Port 1', maximum_draw=100, allocated_draw=50, description='First'),
|
||||
PowerPort(device=devices[1], name='Power Port 2', maximum_draw=200, allocated_draw=100, description='Second'),
|
||||
PowerPort(device=devices[2], name='Power Port 3', maximum_draw=300, allocated_draw=150, description='Third'),
|
||||
PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'),
|
||||
PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'),
|
||||
PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
@@ -1702,6 +1707,10 @@ class PowerPortTestCase(TestCase):
|
||||
params = {'name': ['Power Port 1', 'Power Port 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1787,9 +1796,9 @@ class PowerOutletTestCase(TestCase):
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
power_outlets = (
|
||||
PowerOutlet(device=devices[0], name='Power Outlet 1', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
|
||||
PowerOutlet(device=devices[1], name='Power Outlet 2', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
|
||||
PowerOutlet(device=devices[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
|
||||
PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
|
||||
PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
|
||||
PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
|
||||
@@ -1806,6 +1815,10 @@ class PowerOutletTestCase(TestCase):
|
||||
params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1882,12 +1895,12 @@ class InterfaceTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
|
||||
Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
|
||||
Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'),
|
||||
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
|
||||
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
|
||||
Interface(device=devices[3], name='Interface 6', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False),
|
||||
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
|
||||
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
|
||||
Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'),
|
||||
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
|
||||
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
|
||||
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@@ -1904,6 +1917,10 @@ class InterfaceTestCase(TestCase):
|
||||
params = {'name': ['Interface 1', 'Interface 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
@@ -2019,12 +2036,12 @@ class FrontPortTestCase(TestCase):
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
front_ports = (
|
||||
FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'),
|
||||
FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
|
||||
FrontPort(device=devices[2], name='Front Port 3', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
|
||||
FrontPort(device=devices[3], name='Front Port 4', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 5', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 6', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1),
|
||||
FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'),
|
||||
FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
|
||||
FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
|
||||
FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1),
|
||||
)
|
||||
FrontPort.objects.bulk_create(front_ports)
|
||||
|
||||
@@ -2041,6 +2058,10 @@ class FrontPortTestCase(TestCase):
|
||||
params = {'name': ['Front Port 1', 'Front Port 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
@@ -2111,12 +2132,12 @@ class RearPortTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
rear_ports = (
|
||||
RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'),
|
||||
RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'),
|
||||
RearPort(device=devices[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'),
|
||||
RearPort(device=devices[3], name='Rear Port 4', type=PortTypeChoices.TYPE_FC, positions=4),
|
||||
RearPort(device=devices[3], name='Rear Port 5', type=PortTypeChoices.TYPE_FC, positions=5),
|
||||
RearPort(device=devices[3], name='Rear Port 6', type=PortTypeChoices.TYPE_FC, positions=6),
|
||||
RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'),
|
||||
RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'),
|
||||
RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'),
|
||||
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
|
||||
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
|
||||
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
|
||||
)
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
@@ -2133,6 +2154,10 @@ class RearPortTestCase(TestCase):
|
||||
params = {'name': ['Rear Port 1', 'Rear Port 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
@@ -2206,9 +2231,9 @@ class DeviceBayTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
device_bays = (
|
||||
DeviceBay(device=devices[0], name='Device Bay 1', description='First'),
|
||||
DeviceBay(device=devices[1], name='Device Bay 2', description='Second'),
|
||||
DeviceBay(device=devices[2], name='Device Bay 3', description='Third'),
|
||||
DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
|
||||
DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
|
||||
DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
|
||||
)
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
|
||||
@@ -2220,6 +2245,10 @@ class DeviceBayTestCase(TestCase):
|
||||
params = {'name': ['Device Bay 1', 'Device Bay 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -2286,9 +2315,9 @@ class InventoryItemTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
inventory_items = (
|
||||
InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
|
||||
InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
|
||||
InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
|
||||
InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
|
||||
InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
|
||||
InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
|
||||
)
|
||||
for i in inventory_items:
|
||||
i.save()
|
||||
@@ -2309,6 +2338,10 @@ class InventoryItemTestCase(TestCase):
|
||||
params = {'name': ['Inventory Item 1', 'Inventory Item 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_part_id(self):
|
||||
params = {'part_id': ['1001', '1002']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -2402,9 +2435,9 @@ class VirtualChassisTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
virtual_chassis = (
|
||||
VirtualChassis(master=devices[0], domain='Domain 1'),
|
||||
VirtualChassis(master=devices[2], domain='Domain 2'),
|
||||
VirtualChassis(master=devices[4], domain='Domain 3'),
|
||||
VirtualChassis(name='VC 1', master=devices[0], domain='Domain 1'),
|
||||
VirtualChassis(name='VC 2', master=devices[2], domain='Domain 2'),
|
||||
VirtualChassis(name='VC 3', master=devices[4], domain='Domain 3'),
|
||||
)
|
||||
VirtualChassis.objects.bulk_create(virtual_chassis)
|
||||
|
||||
@@ -2420,6 +2453,17 @@ class VirtualChassisTestCase(TestCase):
|
||||
params = {'domain': ['Domain 1', 'Domain 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_master(self):
|
||||
masters = Device.objects.all()
|
||||
params = {'master_id': [masters[0].pk, masters[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'master': [masters[0].name, masters[2].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['VC 1', 'VC 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
|
||||
@@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertTrue(form.save())
|
||||
|
||||
def test_non_racked_device_with_face_position(self):
|
||||
def test_non_racked_device_with_face(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'New Device',
|
||||
'device_role': DeviceRole.objects.first().pk,
|
||||
@@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'face': DeviceFaceChoices.FACE_REAR,
|
||||
'position': 10,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('face', form.errors)
|
||||
|
||||
def test_non_racked_device_with_position(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'New Device',
|
||||
'device_role': DeviceRole.objects.first().pk,
|
||||
'tenant': None,
|
||||
'manufacturer': Manufacturer.objects.first().pk,
|
||||
'device_type': DeviceType.objects.first().pk,
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'position': 10,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,42 @@ from dcim.models import *
|
||||
from tenancy.models import Tenant
|
||||
|
||||
|
||||
class RackGroupTestCase(TestCase):
|
||||
|
||||
def test_change_rackgroup_site(self):
|
||||
"""
|
||||
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
|
||||
Site A
|
||||
- RackGroup A1
|
||||
- RackGroup A2
|
||||
- Rack 2
|
||||
- Rack 1
|
||||
"""
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
|
||||
rackgroup_a1.save()
|
||||
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
|
||||
rackgroup_a2.save()
|
||||
|
||||
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
|
||||
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
|
||||
|
||||
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
|
||||
|
||||
# Move RackGroup A1 to Site B
|
||||
rackgroup_a1.site = site_b
|
||||
rackgroup_a1.save()
|
||||
|
||||
# Check that all objects within RackGroup A1 now belong to Site B
|
||||
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
|
||||
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
|
||||
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
|
||||
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
|
||||
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
|
||||
|
||||
|
||||
class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@@ -154,6 +190,34 @@ class RackTestCase(TestCase):
|
||||
)
|
||||
self.assertTrue(pdu)
|
||||
|
||||
def test_change_rack_site(self):
|
||||
"""
|
||||
Check that child Devices get updated when a Rack is moved to a new Site.
|
||||
"""
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Device Role 1', slug='device-role-1', color='ff0000'
|
||||
)
|
||||
|
||||
# Create Rack1 in Site A
|
||||
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
|
||||
|
||||
# Create Device1 in Rack1
|
||||
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
|
||||
|
||||
# Move Rack1 to Site B
|
||||
rack1.site = site_b
|
||||
rack1.save()
|
||||
|
||||
# Check that Device1 is now assigned to Site B
|
||||
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'comments': 'New comments',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_list_rack_elevations(self):
|
||||
"""
|
||||
Test viewing the list of rack elevations.
|
||||
"""
|
||||
response = self.client.get(reverse('dcim:rack_elevation_list'))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
|
||||
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = Manufacturer
|
||||
@@ -388,6 +396,7 @@ manufacturer: Generic
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
u_height: 2
|
||||
subdevice_role: parent
|
||||
comments: test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@@ -823,8 +832,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from secrets.models import Secret
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import csv_format, get_subquery
|
||||
from utilities.utils import csv_format, count_related
|
||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
@@ -254,7 +254,7 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RackRoleListView(generic.ObjectListView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=get_subquery(Rack, 'role')
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
@@ -276,7 +276,7 @@ class RackRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=get_subquery(Rack, 'role')
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
@@ -289,7 +289,7 @@ class RackListView(generic.ObjectListView):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'devices__device_type'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack')
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filters.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
@@ -470,9 +470,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@@ -494,7 +494,7 @@ class ManufacturerBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@@ -505,7 +505,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceTypeListView(generic.ObjectListView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
filterset_form = forms.DeviceTypeFilterForm
|
||||
@@ -612,7 +612,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
|
||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
@@ -621,7 +621,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
@@ -913,8 +913,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceRoleListView(generic.ObjectListView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
vm_count=get_subquery(VirtualMachine, 'role')
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
@@ -945,8 +945,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PlatformListView(generic.ObjectListView):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
vm_count=get_subquery(VirtualMachine, 'platform')
|
||||
device_count=count_related(Device, 'platform'),
|
||||
vm_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
table = tables.PlatformTable
|
||||
|
||||
@@ -2134,10 +2134,14 @@ class PathTraceView(generic.ObjectView):
|
||||
else:
|
||||
path = related_paths.first()
|
||||
|
||||
# Get the total length of the cable and whether the length is definitive (fully defined)
|
||||
total_length, is_definitive = path.get_total_length() if path else (None, False)
|
||||
|
||||
return {
|
||||
'path': path,
|
||||
'related_paths': related_paths,
|
||||
'total_length': path.get_total_length() if path else None,
|
||||
'total_length': total_length,
|
||||
'is_definitive': is_definitive
|
||||
}
|
||||
|
||||
|
||||
@@ -2335,7 +2339,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=get_subquery(Device, 'virtual_chassis')
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
table = tables.VirtualChassisTable
|
||||
filterset = filters.VirtualChassisFilterSet
|
||||
@@ -2565,7 +2569,7 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
filterset_form = forms.PowerPanelFilterForm
|
||||
@@ -2615,7 +2619,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
table = tables.PowerPanelTable
|
||||
|
||||
@@ -21,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.utils import copy_safe_request, get_subquery
|
||||
from utilities.utils import copy_safe_request, count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ class ConfigContextQuerySetMixin:
|
||||
Provides a get_queryset() method which deals with adding the config context
|
||||
data annotation or not.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Build the proper queryset based on the request context
|
||||
@@ -49,11 +48,11 @@ class ConfigContextQuerySetMixin:
|
||||
|
||||
Else, return the queryset annotated with config context data
|
||||
"""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
|
||||
return self.queryset
|
||||
return self.queryset.annotate_config_context_data()
|
||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
||||
return queryset
|
||||
return queryset.annotate_config_context_data()
|
||||
|
||||
|
||||
#
|
||||
@@ -102,7 +101,7 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=get_subquery(TaggedItem, 'tag')
|
||||
tagged_items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filters.TagFilterSet
|
||||
|
||||
@@ -2,6 +2,7 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.forms import DateField, IntegerField, NullBooleanField
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
def __init__(self, custom_field, *args, **kwargs):
|
||||
self.custom_field = custom_field
|
||||
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
self.field_class = IntegerField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
self.field_class = NullBooleanField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
self.field_class = DateField
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
# Skip filter on empty value
|
||||
if value is None or not value.strip():
|
||||
return queryset
|
||||
|
||||
# Apply the assigned filter logic (exact or loose)
|
||||
if (
|
||||
self.custom_field.type in EXACT_FILTER_TYPES or
|
||||
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||
):
|
||||
kwargs = {f'custom_field_data__{self.field_name}': value}
|
||||
else:
|
||||
kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
|
||||
|
||||
return queryset.filter(**kwargs)
|
||||
if custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
class CustomFieldModelFilterSet(django_filters.FilterSet):
|
||||
|
||||
@@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields.append(field_name)
|
||||
|
||||
def save(self, commit=True):
|
||||
def clean(self):
|
||||
|
||||
# Save custom field data on instance
|
||||
for cf_name in self.custom_fields:
|
||||
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
||||
|
||||
return super().save(commit)
|
||||
return super().clean()
|
||||
|
||||
|
||||
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from cacheops import invalidate_model
|
||||
from django.apps import apps
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
@@ -27,7 +28,7 @@ class Command(BaseCommand):
|
||||
app_label, model_name = name.split('.')
|
||||
except ValueError:
|
||||
raise CommandError(
|
||||
"Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
|
||||
f"Invalid format: {name}. Models must be specified in the form app_label.ModelName."
|
||||
)
|
||||
try:
|
||||
app_config = apps.get_app_config(app_label)
|
||||
@@ -36,13 +37,13 @@ class Command(BaseCommand):
|
||||
try:
|
||||
model = app_config.get_model(model_name)
|
||||
except LookupError:
|
||||
raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
|
||||
raise CommandError(f"Unknown model: {app_label}.{model_name}")
|
||||
fields = [
|
||||
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
|
||||
]
|
||||
if not fields:
|
||||
raise CommandError(
|
||||
"Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
|
||||
f"Invalid model: {app_label}.{model_name} does not employ natural ordering"
|
||||
)
|
||||
models.append(
|
||||
(model, fields)
|
||||
@@ -67,7 +68,7 @@ class Command(BaseCommand):
|
||||
models = self._get_models(args)
|
||||
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Renaturalizing {} models.".format(len(models)))
|
||||
self.stdout.write(f"Renaturalizing {len(models)} models.")
|
||||
|
||||
for model, fields in models:
|
||||
for field in fields:
|
||||
@@ -78,7 +79,7 @@ class Command(BaseCommand):
|
||||
# Print the model and field name
|
||||
if options['verbosity']:
|
||||
self.stdout.write(
|
||||
"{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
|
||||
f"{model._meta.label}.{field.target_field} ({field.name})... ",
|
||||
ending='\n' if options['verbosity'] >= 2 else ''
|
||||
)
|
||||
self.stdout.flush()
|
||||
@@ -89,23 +90,26 @@ class Command(BaseCommand):
|
||||
naturalized_value = naturalize(value, max_length=field.max_length)
|
||||
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='')
|
||||
self.stdout.write(f" {value} -> {naturalized_value}", ending='')
|
||||
self.stdout.flush()
|
||||
|
||||
# Update each unique field value in bulk
|
||||
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
|
||||
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(" ({})".format(changed))
|
||||
self.stdout.write(f" ({changed})")
|
||||
count += changed
|
||||
|
||||
# Print the total count of alterations for the field
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
|
||||
count, model._meta.verbose_name_plural, queryset.count()
|
||||
)))
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"{count} {model._meta.verbose_name_plural} updated ({queryset.count()} unique values)"
|
||||
))
|
||||
elif options['verbosity']:
|
||||
self.stdout.write(self.style.SUCCESS(str(count)))
|
||||
|
||||
# Invalidate cached queries
|
||||
invalidate_model(model)
|
||||
|
||||
if options['verbosity']:
|
||||
self.stdout.write(self.style.SUCCESS("Done."))
|
||||
|
||||
@@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
|
||||
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
|
||||
try:
|
||||
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
|
||||
except ValueError as e:
|
||||
except Exception as e:
|
||||
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
|
||||
raise e
|
||||
model.objects.filter(pk=cfv.obj_id).update(**cf_data)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
|
||||
])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
|
||||
|
||||
# Validate all field values
|
||||
@@ -172,6 +174,8 @@ class CustomField(models.Model):
|
||||
obj.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the field's default value (if any)
|
||||
if self.default is not None:
|
||||
try:
|
||||
@@ -192,7 +196,8 @@ class CustomField(models.Model):
|
||||
})
|
||||
|
||||
# Regex validation can be set only for text fields
|
||||
if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
|
||||
regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL)
|
||||
if self.validation_regex and self.type not in regex_types:
|
||||
raise ValidationError({
|
||||
'validation_regex': "Regular expression validation is supported only for text and URL fields"
|
||||
})
|
||||
@@ -317,10 +322,11 @@ class CustomField(models.Model):
|
||||
|
||||
# Validate date
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
if type(value) is not date:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
|
||||
# Validate selected choice
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
|
||||
@@ -117,11 +117,15 @@ class Webhook(models.Model):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# At least one action type must be selected
|
||||
if not self.type_create and not self.type_delete and not self.type_update:
|
||||
raise ValidationError(
|
||||
"You must select at least one type: create, update, and/or delete."
|
||||
)
|
||||
|
||||
# CA file path requires SSL verification enabled
|
||||
if not self.ssl_verification and self.ca_file_path:
|
||||
raise ValidationError({
|
||||
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
|
||||
@@ -436,6 +440,7 @@ class ConfigContext(ChangeLoggedModel):
|
||||
return reverse('extras:configcontext', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
@@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
|
||||
@@ -89,6 +89,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
}
|
||||
base_query = Q(
|
||||
Q(platforms=OuterRef('platform')) | Q(platforms=None),
|
||||
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
|
||||
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
|
||||
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
|
||||
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
|
||||
Q(
|
||||
@@ -111,8 +113,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
|
||||
elif self.model._meta.model_name == 'virtualmachine':
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
|
||||
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
|
||||
region_field = 'cluster__site__region'
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([ct])
|
||||
|
||||
# Create a select custom field on the Site model
|
||||
cf_select = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||
form_data = {
|
||||
@@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-1',
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'cf_my_field': 'ABC',
|
||||
'cf_my_field_select': 'Bar',
|
||||
'tags': [tag.pk for tag in tags],
|
||||
}
|
||||
|
||||
@@ -54,6 +65,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc_list[0].changed_object, site)
|
||||
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
@@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-x',
|
||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||
'cf_my_field': 'DEF',
|
||||
'cf_my_field_select': 'Foo',
|
||||
'tags': [tags[2].pk],
|
||||
}
|
||||
|
||||
@@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
@@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
|
||||
@@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([ct])
|
||||
|
||||
# Create a select custom field on the Site model
|
||||
cf_select = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
|
||||
# Create some tags
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
@@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 1'},
|
||||
@@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
'my_field': 'DEF',
|
||||
'my_field_select': 'Foo',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 3'}
|
||||
@@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.filters import SiteFilterSet
|
||||
from dcim.forms import SiteCSVForm
|
||||
from dcim.models import Site, Rack
|
||||
from extras.choices import *
|
||||
@@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
site.cf['baz'] = 'def'
|
||||
site.clean()
|
||||
|
||||
|
||||
class CustomFieldFilterTest(TestCase):
|
||||
queryset = Site.objects.all()
|
||||
filterset = SiteFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Integer filtering
|
||||
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Boolean filtering
|
||||
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Exact text filtering
|
||||
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Loose text filtering
|
||||
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Date filtering
|
||||
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Exact URL filtering
|
||||
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Loose URL filtering
|
||||
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Selection filtering
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site 1', slug='site-1', custom_field_data={
|
||||
'cf1': 100,
|
||||
'cf2': True,
|
||||
'cf3': 'foo',
|
||||
'cf4': 'foo',
|
||||
'cf5': '2016-06-26',
|
||||
'cf6': 'http://foo.example.com/',
|
||||
'cf7': 'http://foo.example.com/',
|
||||
'cf8': 'Foo',
|
||||
}),
|
||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||
'cf1': 200,
|
||||
'cf2': False,
|
||||
'cf3': 'foobar',
|
||||
'cf4': 'foobar',
|
||||
'cf5': '2016-06-27',
|
||||
'cf6': 'http://bar.example.com/',
|
||||
'cf7': 'http://bar.example.com/',
|
||||
'cf8': 'Bar',
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||
}),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_boolean(self):
|
||||
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_text(self):
|
||||
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_date(self):
|
||||
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_url(self):
|
||||
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -12,7 +12,7 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict
|
||||
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
@@ -27,7 +27,7 @@ from .scripts import get_scripts, run_script
|
||||
|
||||
class TagListView(generic.ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
filterset = filters.TagFilterSet
|
||||
filterset_form = forms.TagFilterForm
|
||||
@@ -52,7 +52,7 @@ class TagBulkImportView(generic.BulkImportView):
|
||||
|
||||
class TagBulkEditView(generic.BulkEditView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
@@ -60,7 +60,7 @@ class TagBulkEditView(generic.BulkEditView):
|
||||
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import random
|
||||
import secrets
|
||||
|
||||
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
|
||||
secure_random = random.SystemRandom()
|
||||
print(''.join(secure_random.sample(charset, 50)))
|
||||
print(''.join(secrets.choice(charset) for _ in range(50)))
|
||||
|
||||
@@ -25,8 +25,18 @@ from .nested_serializers import *
|
||||
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
|
||||
export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
|
||||
import_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
export_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
ipaddress_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -12,7 +14,7 @@ from ipam import filters
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -32,8 +34,8 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
|
||||
'import_targets', 'export_targets', 'tags'
|
||||
).annotate(
|
||||
ipaddress_count=get_subquery(IPAddress, 'vrf'),
|
||||
prefix_count=get_subquery(Prefix, 'vrf')
|
||||
ipaddress_count=count_related(IPAddress, 'vrf'),
|
||||
prefix_count=count_related(Prefix, 'vrf')
|
||||
)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilterSet
|
||||
@@ -55,7 +57,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=get_subquery(Aggregate, 'rir')
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilterSet
|
||||
@@ -77,8 +79,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilterSet
|
||||
@@ -162,7 +164,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -178,7 +185,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
|
||||
request_body=serializers.AvailableIPSerializer(many=False))
|
||||
request_body=serializers.AvailableIPSerializer(many=True))
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def available_ips(self, request, pk=None):
|
||||
@@ -225,7 +232,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -272,7 +284,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=get_subquery(VLAN, 'group')
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filters.VLANGroupFilterSet
|
||||
@@ -286,7 +298,7 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'vlan')
|
||||
prefix_count=count_related(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
||||
@@ -192,7 +192,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
|
||||
field_name='prefix',
|
||||
lookup_expr='family'
|
||||
)
|
||||
prefix = django_filters.CharFilter(
|
||||
prefix = MultiValueCharFilter(
|
||||
method='filter_prefix',
|
||||
label='Prefix',
|
||||
)
|
||||
@@ -304,13 +304,13 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def filter_prefix(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(value).cidr)
|
||||
return queryset.filter(prefix=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
query_values = []
|
||||
for v in value:
|
||||
try:
|
||||
query_values.append(netaddr.IPNetwork(v))
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
return queryset.filter(prefix__in=query_values)
|
||||
|
||||
def search_within(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
|
||||
@@ -465,12 +465,14 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
if data:
|
||||
|
||||
# Limit vlan queryset by assigned site and group
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'),
|
||||
}
|
||||
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
|
||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||
params = {}
|
||||
if data.get('site'):
|
||||
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
|
||||
if data.get('vlan_group'):
|
||||
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
|
||||
if params:
|
||||
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
|
||||
|
||||
|
||||
class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
@@ -774,6 +776,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
|
||||
@@ -734,13 +734,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
|
||||
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
|
||||
) or (
|
||||
self.vrf and self.vrf.enforce_unique
|
||||
)):
|
||||
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
if duplicate_ips:
|
||||
if duplicate_ips and (
|
||||
self.role not in IPADDRESS_ROLES_NONUNIQUE or
|
||||
any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips)
|
||||
):
|
||||
raise ValidationError({
|
||||
'address': "Duplicate IP address found in {}: {}".format(
|
||||
"VRF {}".format(self.vrf) if self.vrf else "global table",
|
||||
|
||||
@@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% if record.children %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a>
|
||||
{% else %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}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 %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
</span>
|
||||
{% load helpers %}
|
||||
{% for i in record.parents|as_range %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
@@ -39,7 +37,7 @@ IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
{% elif perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||
{% else %}
|
||||
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
|
||||
{% endif %}
|
||||
@@ -52,8 +50,8 @@ IPADDRESS_ASSIGN_LINK = """
|
||||
VRF_LINK = """
|
||||
{% if record.vrf %}
|
||||
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
|
||||
{% elif prefix.vrf %}
|
||||
{{ prefix.vrf }}
|
||||
{% elif object.vrf %}
|
||||
<a href="{{ object.vrf.get_absolute_url }}">{{ object.vrf }}</a>
|
||||
{% else %}
|
||||
Global
|
||||
{% endif %}
|
||||
@@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
{% if record.untagged_vlan_id == object.pk %}
|
||||
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
@@ -116,6 +114,8 @@ TENANT_LINK = """
|
||||
<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 %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
|
||||
{% elif object.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=object.tenant.slug %}" title="{{ object.tenant.description }}">{{ object.tenant }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@@ -272,7 +272,7 @@ class PrefixTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
prefix = tables.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
attrs={'th': {'style': 'padding-left: 17px'}}
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
@@ -433,6 +433,9 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=IPAddress
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
|
||||
@@ -422,6 +422,11 @@ class PrefixTestCase(TestCase):
|
||||
params = {'family': '6'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_prefix(self):
|
||||
prefixes = Prefix.objects.all()[:2]
|
||||
params = {'prefix': [prefixes[0].prefix, prefixes[1].prefix]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_is_pool(self):
|
||||
params = {'is_pool': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -259,6 +259,18 @@ class TestIPAddress(TestCase):
|
||||
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_nonrole_role(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_role_nonrole(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_role(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
|
||||
@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
|
||||
from dcim.models import Device, Interface
|
||||
from netbox.views import generic
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from . import filters, forms, tables
|
||||
from .constants import *
|
||||
@@ -30,6 +30,7 @@ class VRFView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count()
|
||||
ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
|
||||
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
instance.import_targets.prefetch_related('tenant'),
|
||||
@@ -42,6 +43,7 @@ class VRFView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'prefix_count': prefix_count,
|
||||
'ipaddress_count': ipaddress_count,
|
||||
'import_targets_table': import_targets_table,
|
||||
'export_targets_table': export_targets_table,
|
||||
}
|
||||
@@ -140,7 +142,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RIRListView(generic.ObjectListView):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=get_subquery(Aggregate, 'rir')
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
filterset_form = forms.RIRFilterForm
|
||||
@@ -165,7 +167,7 @@ class RIRBulkImportView(generic.BulkImportView):
|
||||
|
||||
class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=get_subquery(Aggregate, 'rir')
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
table = tables.RIRTable
|
||||
@@ -277,8 +279,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RoleListView(generic.ObjectListView):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
table = tables.RoleTable
|
||||
|
||||
@@ -633,7 +635,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=get_subquery(VLAN, 'group')
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
filterset_form = forms.VLANGroupFilterForm
|
||||
@@ -657,7 +659,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=get_subquery(VLAN, 'group')
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
@@ -804,7 +806,7 @@ class ServiceListView(generic.ObjectListView):
|
||||
filterset = filters.ServiceFilterSet
|
||||
filterset_form = forms.ServiceFilterForm
|
||||
table = tables.ServiceTable
|
||||
action_buttons = ('export',)
|
||||
action_buttons = ('import', 'export')
|
||||
|
||||
|
||||
class ServiceView(generic.ObjectView):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
|
||||
from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from .routers import OrderedDefaultRouter
|
||||
from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||
|
||||
@@ -9,7 +9,6 @@ __all__ = (
|
||||
'ContentTypeField',
|
||||
'OrderedDefaultRouter',
|
||||
'SerializedPKRelatedField',
|
||||
'TimeZoneField',
|
||||
'ValidatedModelSerializer',
|
||||
'WritableNestedSerializer',
|
||||
)
|
||||
|
||||
@@ -104,21 +104,6 @@ class ContentTypeField(RelatedField):
|
||||
return f"{obj.app_label}.{obj.model}"
|
||||
|
||||
|
||||
class TimeZoneField(serializers.Field):
|
||||
"""
|
||||
Represent a pytz time zone.
|
||||
"""
|
||||
def to_representation(self, obj):
|
||||
return obj.zone if obj else None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not data:
|
||||
return ""
|
||||
if data not in pytz.common_timezones:
|
||||
raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
|
||||
return pytz.timezone(data)
|
||||
|
||||
|
||||
class SerializedPKRelatedField(PrimaryKeyRelatedField):
|
||||
"""
|
||||
Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
from django.utils.encoding import force_str
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.request import clone_request
|
||||
|
||||
from netbox.api import ContentTypeField
|
||||
|
||||
|
||||
class ContentTypeMetadata(SimpleMetadata):
|
||||
class BulkOperationMetadata(SimpleMetadata):
|
||||
|
||||
def determine_actions(self, request, view):
|
||||
"""
|
||||
Replace the stock determine_actions() method to assess object permissions only
|
||||
when viewing a specific object. This is necessary to support OPTIONS requests
|
||||
with bulk update in place (see #5470).
|
||||
"""
|
||||
actions = {}
|
||||
for method in {'PUT', 'POST'} & set(view.allowed_methods):
|
||||
view.request = clone_request(request, method)
|
||||
try:
|
||||
# Test global permissions
|
||||
if hasattr(view, 'check_permissions'):
|
||||
view.check_permissions(view.request)
|
||||
# Test object permissions (if viewing a specific object)
|
||||
if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'):
|
||||
view.get_object()
|
||||
except (exceptions.APIException, PermissionDenied, Http404):
|
||||
pass
|
||||
else:
|
||||
# If user has appropriate permissions for the view, include
|
||||
# appropriate metadata about the fields that should be supplied.
|
||||
serializer = view.get_serializer()
|
||||
actions[method] = self.get_serializer_info(serializer)
|
||||
finally:
|
||||
view.request = request
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
class ContentTypeMetadata(BulkOperationMetadata):
|
||||
|
||||
def get_field_info(self, field):
|
||||
field_info = super().get_field_info(field)
|
||||
|
||||
@@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import mixins, status
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
|
||||
from rq.worker import Worker
|
||||
|
||||
from netbox.api import BulkOperationSerializer
|
||||
@@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ModelViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
BulkUpdateModelMixin,
|
||||
BulkDestroyModelMixin,
|
||||
GenericViewSet):
|
||||
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
"""
|
||||
Accept either a single object or a list of objects to create.
|
||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||
"""
|
||||
brief = False
|
||||
brief_prefetch_fields = []
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
@@ -142,22 +138,34 @@ class ModelViewSet(mixins.CreateModelMixin,
|
||||
def get_serializer_class(self):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
|
||||
# exists
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief'):
|
||||
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
||||
if self.brief:
|
||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||
try:
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
logger.debug(f"Using serializer {serializer}")
|
||||
return serializer
|
||||
except SerializerNotFound:
|
||||
pass
|
||||
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
|
||||
|
||||
# Fall back to the hard-coded serializer class
|
||||
logger.debug(f"Using serializer {self.serializer_class}")
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
def initialize_request(self, request, *args, **kwargs):
|
||||
# Check if brief=True has been passed
|
||||
if request.method == 'GET' and request.GET.get('brief'):
|
||||
self.brief = True
|
||||
|
||||
return super().initialize_request(request, *args, **kwargs)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
|
||||
# Text to include on the login page above the login form. HTML is allowed.
|
||||
BANNER_LOGIN = ''
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
@@ -154,6 +154,9 @@ LOGIN_TIMEOUT = None
|
||||
# Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
MAINTENANCE_MODE = False
|
||||
|
||||
# The URL to use when mapping physical addresses or GPS coordinates
|
||||
MAPS_URL = 'https://maps.google.com/?q='
|
||||
|
||||
# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g.
|
||||
# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request
|
||||
# all objects by specifying "?limit=0".
|
||||
@@ -183,7 +186,7 @@ NAPALM_PASSWORD = ''
|
||||
# NAPALM timeout (in seconds). (Default: 30)
|
||||
NAPALM_TIMEOUT = 30
|
||||
|
||||
# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
|
||||
# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
|
||||
# be provided as a dictionary.
|
||||
NAPALM_ARGS = {}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from secrets.tables import SecretTable
|
||||
from tenancy.filters import TenantFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.tables import TenantTable
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
|
||||
@@ -33,7 +33,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
@@ -74,7 +74,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
@@ -90,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=get_subquery(Device, 'virtual_chassis')
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
@@ -111,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.10.0'
|
||||
VERSION = '2.10.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -88,10 +88,9 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
|
||||
MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=')
|
||||
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
|
||||
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
|
||||
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
|
||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
|
||||
NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
|
||||
@@ -118,18 +117,23 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
|
||||
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
|
||||
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
|
||||
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
|
||||
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
|
||||
# Validate update repo URL and timeout
|
||||
if RELEASE_CHECK_URL:
|
||||
try:
|
||||
URLValidator(RELEASE_CHECK_URL)
|
||||
except ValidationError:
|
||||
raise ImproperlyConfigured(
|
||||
validator = URLValidator(
|
||||
message=(
|
||||
"RELEASE_CHECK_URL must be a valid API URL. Example: "
|
||||
"https://api.github.com/repos/netbox-community/netbox"
|
||||
)
|
||||
)
|
||||
try:
|
||||
validator(RELEASE_CHECK_URL)
|
||||
except ValidationError as err:
|
||||
raise ImproperlyConfigured(str(err))
|
||||
|
||||
# Enforce a minimum cache timeout for update checks
|
||||
if RELEASE_CHECK_TIMEOUT < 3600:
|
||||
@@ -391,6 +395,7 @@ if CACHING_REDIS_USING_SENTINEL:
|
||||
'locations': CACHING_REDIS_SENTINELS,
|
||||
'service_name': CACHING_REDIS_SENTINEL_SERVICE,
|
||||
'db': CACHING_REDIS_DATABASE,
|
||||
'password': CACHING_REDIS_PASSWORD,
|
||||
}
|
||||
else:
|
||||
if CACHING_REDIS_SSL:
|
||||
@@ -467,6 +472,7 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
|
||||
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'netbox.api.authentication.TokenPermissions',
|
||||
|
||||
@@ -792,14 +792,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
elif form.cleaned_data[name] not in (None, '', []):
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
|
||||
# Update custom fields
|
||||
for name in custom_fields:
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
obj.custom_field_data.pop(name, None)
|
||||
else:
|
||||
obj.custom_field_data[name] = None
|
||||
elif form.cleaned_data.get(name) not in (None, ''):
|
||||
obj.custom_field_data[name] = form.cleaned_data[name]
|
||||
|
||||
obj.full_clean()
|
||||
|
||||
@@ -14,21 +14,21 @@ body {
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
|
||||
margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
.navbar-brand {
|
||||
padding: 12px 15px 8px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 60px; /* .push must be the same height as .footer */
|
||||
height: 48px; /* .push must be the same height as .footer */
|
||||
}
|
||||
.footer {
|
||||
background-color: #f5f5f5;
|
||||
border-top: 1px solid #d0d0d0;
|
||||
}
|
||||
footer p {
|
||||
margin: 20px 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
#navbar_search {
|
||||
padding: 0 8px;
|
||||
@@ -177,6 +177,10 @@ nav ul.pagination {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.pagination > li > a > .mdi::before {
|
||||
top: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Devices */
|
||||
table.component-list td.subtable {
|
||||
@@ -358,9 +362,6 @@ table.report th a {
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.banner-bottom {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.panel table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Inteface filtering
|
||||
$('input.interface-filter').on('input', function() {
|
||||
var filter = new RegExp(this.value);
|
||||
var interface;
|
||||
let filter = new RegExp(this.value);
|
||||
let interface;
|
||||
|
||||
for (interface of $('table > tbody > tr')) {
|
||||
// Slice off 'interface_' at the start of the ID
|
||||
if (filter.test(interface.id.slice(10))) {
|
||||
if (filter.test(interface.getAttribute('data-name'))) {
|
||||
// Match the toggle in case the filter now matches the interface
|
||||
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
|
||||
$(interface).show();
|
||||
|
||||
@@ -12,7 +12,7 @@ from netbox.api.views import ModelViewSet
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@@ -35,7 +35,7 @@ class SecretsRootView(APIRootView):
|
||||
|
||||
class SecretRoleViewSet(ModelViewSet):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=get_subquery(Secret, 'role')
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
filterset_class = filters.SecretRoleFilterSet
|
||||
|
||||
@@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
|
||||
raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .models import SecretRole, Secret, SessionKey, UserKey
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_session_key(request):
|
||||
|
||||
class SecretRoleListView(generic.ObjectListView):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=get_subquery(Secret, 'role')
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
table = tables.SecretRoleTable
|
||||
|
||||
@@ -50,7 +50,7 @@ class SecretRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
class SecretRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=get_subquery(Secret, 'role')
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
table = tables.SecretRoleTable
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{% block content %}{% endblock %}
|
||||
<div class="push"></div>
|
||||
{% if settings.BANNER_BOTTOM %}
|
||||
<div class="alert alert-info text-center banner-bottom" role="alert">
|
||||
<div class="alert alert-info text-center" style="margin-bottom: 50px" role="alert">
|
||||
{{ settings.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<div class="col-xs-4 text-right noprint">
|
||||
<p class="text-muted">
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> ·
|
||||
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
<h5>Total segments: {{ traced_path|length }}</h5>
|
||||
<h5>Total length:
|
||||
{% if total_length %}
|
||||
{{ total_length|floatformat:"-2" }} Meters
|
||||
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
|
||||
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if power_ports and poweroutlets %}
|
||||
{% if object.powerports.exists and object.poweroutlets.exists %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Utilization</strong>
|
||||
@@ -217,10 +217,10 @@
|
||||
<th>Available</th>
|
||||
<th>Utilization</th>
|
||||
</tr>
|
||||
{% for pp in power_ports %}
|
||||
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
|
||||
{% for powerport in object.powerports.all %}
|
||||
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
|
||||
<tr>
|
||||
<td>{{ pp }}</td>
|
||||
<td>{{ powerport }}</td>
|
||||
<td>{{ utilization.outlet_count }}</td>
|
||||
<td>{{ utilization.allocated }}VA</td>
|
||||
{% if powerfeed.available_power %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user