mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-04 16:09:31 +01:00
Compare commits
207 Commits
v2.10-beta
...
v2.10.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
91ea12344c | ||
|
|
f572f66426 | ||
|
|
872ba89cad | ||
|
|
1f2daaad59 | ||
|
|
230b503b7a | ||
|
|
a371c08c86 | ||
|
|
55e8d37fb0 | ||
|
|
3f5a2b9c7a | ||
|
|
a589022fcc | ||
|
|
31fc8076e3 | ||
|
|
53f330dbe8 | ||
|
|
13a13f3943 | ||
|
|
00ca227292 | ||
|
|
5c8ed73608 | ||
|
|
5df01ed3e6 | ||
|
|
43d7d5570e | ||
|
|
e062cbb79f | ||
|
|
27e27788cd | ||
|
|
cc5c000a6d | ||
|
|
2c7fc5df75 | ||
|
|
f7e61cc153 | ||
|
|
e7f64334c0 | ||
|
|
584b8109a0 | ||
|
|
d75696b30a | ||
|
|
22bb700f94 | ||
|
|
a4b2e1f2e2 | ||
|
|
6ee161bdc6 | ||
|
|
13349dab6d | ||
|
|
1159976d99 | ||
|
|
407b16c98c | ||
|
|
96ce9122f4 | ||
|
|
a80f13243e | ||
|
|
c166852054 | ||
|
|
ba00d57d37 | ||
|
|
f5133c6737 | ||
|
|
5a4234fcb9 | ||
|
|
3cbe8ddf02 | ||
|
|
8461832a7b | ||
|
|
f504d1aa99 | ||
|
|
2b0ddd2a98 | ||
|
|
cc271aefe1 | ||
|
|
0b57389af6 | ||
|
|
ef77961cf4 | ||
|
|
915cf3e715 | ||
|
|
cadba74b1f | ||
|
|
23b305847e | ||
|
|
f55e966c8f | ||
|
|
77bbe5730b | ||
|
|
f8b523b97b | ||
|
|
d9c503a9ec | ||
|
|
52aa123a9f | ||
|
|
4e25c70f96 | ||
|
|
d250fd91a9 | ||
|
|
6a90295505 | ||
|
|
b4c7727a82 | ||
|
|
9ac64d57d0 | ||
|
|
6f68be5aa7 | ||
|
|
b68c79be6b | ||
|
|
b3d674e170 | ||
|
|
bff9bd5dbd | ||
|
|
e7e63796ac | ||
|
|
e1911a624b | ||
|
|
825cc9a86f | ||
|
|
869c87e396 | ||
|
|
e9fb91b197 | ||
|
|
a081d0d93a | ||
|
|
54af29eb40 | ||
|
|
6cae4d7ee4 | ||
|
|
65d2994823 | ||
|
|
8e0a6479ca | ||
|
|
98f2d2501b | ||
|
|
a05fe69043 | ||
|
|
6fb080ff4c | ||
|
|
9923548628 | ||
|
|
181f5c991b | ||
|
|
3dd2e4c84c | ||
|
|
83f7f9f0e5 | ||
|
|
507fb5387b | ||
|
|
1d7bee5eee | ||
|
|
1d922ee813 | ||
|
|
d237b97776 | ||
|
|
790e1ee2e4 | ||
|
|
e5caea04c1 | ||
|
|
2f1ca902f7 | ||
|
|
d5d87e0fdd | ||
|
|
3e7cf416f1 | ||
|
|
e2d2ff8586 | ||
|
|
11fa348575 | ||
|
|
58668a6807 | ||
|
|
6d379ad029 | ||
|
|
a05fc091c8 | ||
|
|
f2bc6f0f96 | ||
|
|
8ba50d0cf2 | ||
|
|
6dcb2de28f | ||
|
|
1bbfc6da25 | ||
|
|
eecd51e08b | ||
|
|
26abd1f4e2 | ||
|
|
28925c12eb | ||
|
|
d0ac4332ab | ||
|
|
8e5aa69321 | ||
|
|
848cfeb353 | ||
|
|
7b05a18173 | ||
|
|
2ce99929e2 | ||
|
|
f1e82a3647 | ||
|
|
1c5af01a82 | ||
|
|
bac3ace8fc | ||
|
|
68599351aa | ||
|
|
86755029ef | ||
|
|
c507ab30e9 | ||
|
|
7d1614b933 | ||
|
|
a77d1e502c | ||
|
|
d79ed76d80 | ||
|
|
ccf8059452 | ||
|
|
3d3d1bc623 | ||
|
|
e66d065b6d | ||
|
|
c1ef87e009 | ||
|
|
3c249a40a0 | ||
|
|
5092641157 | ||
|
|
472a45ddec | ||
|
|
120cbb0159 | ||
|
|
68fbd9b017 | ||
|
|
3143f75a38 | ||
|
|
e13d4ffe60 | ||
|
|
295d4f0394 | ||
|
|
ea91e09a1b | ||
|
|
946779000f | ||
|
|
5c07b6dc1d | ||
|
|
25c3c1b431 | ||
|
|
a0ae7a227d | ||
|
|
50df3acd26 | ||
|
|
425670f52a |
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,11 +7,9 @@ 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 reproducible bugs. If you need assistance with
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
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
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -8,10 +8,8 @@ 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 post to our mailing list
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
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.
|
||||
|
||||
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: netbox
|
||||
POSTGRES_PASSWORD: netbox
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies & set up configuration
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pycodestyle coverage
|
||||
ln -s configuration.testing.py netbox/netbox/configuration.py
|
||||
|
||||
- name: Check PEP8 compliance
|
||||
run: pycodestyle --ignore=W504,E501 netbox/
|
||||
|
||||
- name: Run tests
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
||||
|
||||
- name: Show coverage report
|
||||
run: coverage report --skip-covered --omit *migrations*
|
||||
20
.travis.yml
20
.travis.yml
@@ -1,20 +0,0 @@
|
||||
sudo: required
|
||||
services:
|
||||
- postgresql
|
||||
- redis-server
|
||||
addons:
|
||||
postgresql: "9.6"
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install pycodestyle
|
||||
- pip install coverage
|
||||
before_script:
|
||||
- psql --version
|
||||
- psql -U postgres -c 'SELECT version();'
|
||||
script:
|
||||
- ./scripts/cibuild.sh
|
||||
@@ -4,15 +4,31 @@ If you encounter any issues installing or using NetBox, try one of the
|
||||
following resources to get assistance. Please **do not** open a GitHub issue
|
||||
except to report bugs or request features.
|
||||
|
||||
### GitHub Discussions
|
||||
|
||||
GitHub's discussions are the best place to get help or propose rough ideas for
|
||||
new functionality. Their integration with GitHub allows for easily cross-
|
||||
referencing and converting posts to issues as needed. There are several
|
||||
categories for discussions:
|
||||
|
||||
* **General** - General community discussion
|
||||
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
|
||||
feature request
|
||||
* **Q&A** - Request help with installing or using NetBox
|
||||
|
||||
### Mailing List
|
||||
|
||||
We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
|
||||
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
|
||||
for general discussion, however we're encouraging people to use GitHub
|
||||
discussions where possible, as it's much easier for newcomers to review past
|
||||
discussions.
|
||||
|
||||
### Slack
|
||||
|
||||
For real-time discussion, 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 [NetworkToCode](https://slack.networktocode.com/).
|
||||
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.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
@@ -93,6 +109,10 @@ appropriate labels will be applied for categorization.
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
* If you're interested in contributing to NetBox, be sure to check out our
|
||||
[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/)
|
||||
documentation for tips on setting up your development environment.
|
||||
|
||||
* Be sure to open an issue **before** starting work on a pull request, and
|
||||
discuss your idea with the NetBox maintainers before beginning work. This will
|
||||
help prevent wasting time on something that might we might not be able to
|
||||
@@ -167,7 +187,7 @@ overlooked.
|
||||
|
||||
* Official channels for communication include:
|
||||
|
||||
* GitHub issues/pull requests
|
||||
* 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/)
|
||||
|
||||
|
||||
29
README.md
29
README.md
@@ -7,20 +7,20 @@ 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 subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
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/)!
|
||||
|
||||
### Build Status
|
||||
|
||||
| | status |
|
||||
|-------------|------------|
|
||||
| **master** | [](https://travis-ci.com/netbox-community/netbox/) |
|
||||
| **develop** | [](https://travis-ci.com/netbox-community/netbox/) |
|
||||
| **master** |  |
|
||||
| **develop** |  |
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,19 +36,22 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
|
||||
|
||||
## Installation
|
||||
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
|
||||
and run `upgrade.sh`.
|
||||
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`.
|
||||
|
||||
## Providing Feedback
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
|
||||
The best platform for general feedback, assistance, and other discussion is our
|
||||
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
|
||||
To report a bug or request a specific feature, please open a GitHub issue using
|
||||
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
|
||||
|
||||
If you are interested in contributing to the development of NetBox, please read
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
## Related projects
|
||||
|
||||
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
|
||||
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
|
||||
for a list of relevant community projects.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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/'
|
||||
@@ -277,7 +277,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
||||
|
||||
Default: False
|
||||
|
||||
Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -318,7 +318,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 = {
|
||||
@@ -460,7 +460,7 @@ NetBox can be configured to support remote user authentication by inferring user
|
||||
|
||||
Default: `'HTTP_REMOTE_USER'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Application Registry
|
||||
|
||||
The registry is an in-memory data structure which houses various miscellaneous application-wide parameters, such as installed plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
|
||||
The registry is an in-memory data structure which houses various application-wide parameters, such as the list of enabled plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
|
||||
|
||||
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, me modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
|
||||
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, be modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
|
||||
|
||||
The registry can be inspected by importing `registry` from `extras.registry`.
|
||||
|
||||
## Stores
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Extending Models
|
||||
|
||||
Below is a list of items to consider when adding a new field to a model:
|
||||
Below is a list of tasks to consider when adding a new field to a core model.
|
||||
|
||||
## 1. Generate and run database migration
|
||||
## 1. Generate and run database migrations
|
||||
|
||||
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
|
||||
|
||||
@@ -14,18 +14,18 @@ Django migrations are used to express changes to the database schema. In most ca
|
||||
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
|
||||
|
||||
!!! note
|
||||
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
|
||||
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
|
||||
|
||||
## 2. Add validation logic to `clean()`
|
||||
|
||||
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
|
||||
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or after your custom validation as appropriate:
|
||||
|
||||
```
|
||||
class Foo(models.Model):
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(DeviceCSVForm, self).clean()
|
||||
super().clean()
|
||||
|
||||
# Custom validation goes here
|
||||
if self.bar is None:
|
||||
@@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
|
||||
|
||||
## 4. Update relevant querysets
|
||||
|
||||
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
|
||||
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
|
||||
|
||||
## 5. Update API serializer
|
||||
|
||||
@@ -49,7 +49,7 @@ Extend the model's API serializer in `<app>.api.serializers` to include the new
|
||||
Extend any forms to include the new field as appropriate. Common forms include:
|
||||
|
||||
* **Credit/edit** - Manipulating a single object
|
||||
* **Bulk edit** - Performing a change on mnay objects at once
|
||||
* **Bulk edit** - Performing a change on many objects at once
|
||||
* **CSV import** - The form used when bulk importing objects in CSV format
|
||||
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
|
||||
|
||||
@@ -59,7 +59,7 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
|
||||
|
||||
## 8. Add column to object table
|
||||
|
||||
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
|
||||
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
|
||||
|
||||
## 9. Update the UI templates
|
||||
|
||||
@@ -76,3 +76,7 @@ Create or extend the relevant test cases to verify that the new field and any ac
|
||||
* View tests
|
||||
|
||||
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
|
||||
|
||||
## 11. Update the model's documentation
|
||||
|
||||
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.
|
||||
|
||||
140
docs/development/getting-started.md
Normal file
140
docs/development/getting-started.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Getting Started
|
||||
|
||||
## Setting up a Development Environment
|
||||
|
||||
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 supported version of Python
|
||||
|
||||
### Fork the Repo
|
||||
|
||||
Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development:
|
||||
|
||||
```no-highlight
|
||||
$ git clone https://github.com/youruseraccount/netbox.git
|
||||
Cloning into 'netbox'...
|
||||
remote: Enumerating objects: 231, done.
|
||||
remote: Counting objects: 100% (231/231), done.
|
||||
remote: Compressing objects: 100% (147/147), done.
|
||||
remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474
|
||||
Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done.
|
||||
Resolving deltas: 100% (44177/44177), done.
|
||||
$ ls netbox/
|
||||
base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh
|
||||
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
|
||||
```
|
||||
|
||||
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
|
||||
* `feature` - Tracks work on an upcoming major release
|
||||
|
||||
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
|
||||
|
||||
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
|
||||
|
||||
```no-highlight
|
||||
$ cd .git/hooks/
|
||||
$ ln -s ../../scripts/git-hooks/pre-commit
|
||||
```
|
||||
|
||||
### Create a Python Virtual Environment
|
||||
|
||||
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
|
||||
|
||||
Create a virtual environment using the `venv` Python module:
|
||||
|
||||
```no-highlight
|
||||
$ mkdir ~/.venv
|
||||
$ python3 -m venv ~/.venv/netbox
|
||||
```
|
||||
|
||||
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
|
||||
|
||||
!!! info
|
||||
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please.
|
||||
|
||||
Once created, activate the virtual environment:
|
||||
|
||||
```no-highlight
|
||||
$ source ~/.venv/netbox/bin/activate
|
||||
(netbox) $
|
||||
```
|
||||
|
||||
Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment.
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
With the virtual environment activated, install the project's required Python packages using the `pip` module:
|
||||
|
||||
```no-highlight
|
||||
(netbox) $ python -m pip install -r requirements.txt
|
||||
Collecting Django==3.1 (from -r requirements.txt (line 1))
|
||||
Cache entry deserialization failed, entry ignored
|
||||
Using cached https://files.pythonhosted.org/packages/2b/5a/4bd5624546912082a1bd2709d0edc0685f5c7827a278d806a20cf6adea28/Django-3.1-py3-none-any.whl
|
||||
...
|
||||
```
|
||||
|
||||
### Configure NetBox
|
||||
|
||||
Within the `netbox/netbox/` directory, copy `configuration.example.py` to `configuration.py` and update the following parameters:
|
||||
|
||||
* `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes
|
||||
* `DATABASE`: PostgreSQL database connection parameters
|
||||
* `REDIS`: Redis configuration, if different from the defaults
|
||||
* `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key)
|
||||
* `DEBUG`: Set to `True`
|
||||
* `DEVELOPER`: Set to `True` (this enables the creation of new database migrations)
|
||||
|
||||
### Start the Development Server
|
||||
|
||||
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command:
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py runserver
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
November 18, 2020 - 15:52:31
|
||||
Django version 3.1, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command:
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test
|
||||
```
|
||||
|
||||
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test --keepdb
|
||||
```
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it.
|
||||
|
||||
```no-highlight
|
||||
$ git commit -m "Closes #1234: Add IPv5 support"
|
||||
$ git push origin
|
||||
```
|
||||
|
||||
Once your fork has the new commit, submit a [pull request](https://github.com/netbox-community/netbox/compare) to the NetBox repo to propose the changes. Be sure to provide a detailed accounting of the changes being made and the reasons for doing so.
|
||||
|
||||
Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
|
||||
|
||||
!!! note
|
||||
Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted.
|
||||
@@ -7,7 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
Communication among developers should always occur via public channels:
|
||||
|
||||
* [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.
|
||||
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting 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.
|
||||
|
||||
## Governance
|
||||
@@ -18,7 +19,7 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo
|
||||
|
||||
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base.
|
||||
|
||||
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function:
|
||||
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function:
|
||||
|
||||
* `circuits`: Communications circuits and providers (not to be confused with power circuits)
|
||||
* `dcim`: Datacenter infrastructure management (sites, racks, and devices)
|
||||
@@ -26,5 +27,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
|
||||
* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs)
|
||||
* `secrets`: Encrypted storage of sensitive data (e.g. login credentials)
|
||||
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
|
||||
* `users`: Authentication and user preferences
|
||||
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
|
||||
* `virtualization`: Virtual machines and clusters
|
||||
|
||||
@@ -16,28 +16,24 @@ The other file is `requirements.txt`, which lists each of the required packages
|
||||
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 via pip:
|
||||
|
||||
```
|
||||
pip install -U -r base_requirements.txt
|
||||
```
|
||||
|
||||
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. Update the package versions in `requirements.txt` as appropriate.
|
||||
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
|
||||
5. Update the package versions in `requirements.txt` as appropriate.
|
||||
|
||||
### Update Static Libraries
|
||||
|
||||
Update the following static libraries to their most recent stable release:
|
||||
|
||||
* Bootstrap 3
|
||||
* Font Awesome 4
|
||||
* Material Design Icons
|
||||
* Select2
|
||||
* jQuery
|
||||
* jQuery UI
|
||||
|
||||
### Create a new Release Notes Page
|
||||
### Link to the Release Notes Page
|
||||
|
||||
Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for the new release. Add the file to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
|
||||
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
@@ -52,7 +48,11 @@ Follow these instructions to perform a new installation of NetBox. This process
|
||||
|
||||
### Close the Release Milestone
|
||||
|
||||
Close the release milestone on GitHub. Ensure that there are no remaining open issues associated with it.
|
||||
Close the release milestone on GitHub after ensuring there are no remaining open issues associated with it.
|
||||
|
||||
### Merge the Release Branch
|
||||
|
||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,11 +64,11 @@ Ensure that continuous integration testing on the `develop` branch is completing
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version.
|
||||
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version. Commit these changes to the `develop` branch.
|
||||
|
||||
### Submit a Pull Request
|
||||
|
||||
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Include a brief change log listing the features, improvements, and/or bugs addressed in the release.
|
||||
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body.
|
||||
|
||||
Once CI has completed on the PR, merge it.
|
||||
|
||||
@@ -76,16 +76,16 @@ Once CI has completed on the PR, merge it.
|
||||
|
||||
Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters.
|
||||
|
||||
* **Tag:** Current version (e.g. `v2.3.4`)
|
||||
* **Tag:** Current version (e.g. `v2.9.9`)
|
||||
* **Target:** `master`
|
||||
* **Title:** Version and date (e.g. `v2.3.4 - 2018-08-02`)
|
||||
* **Title:** Version and date (e.g. `v2.9.9 - 2020-11-09`)
|
||||
|
||||
Copy the description from the pull request into the release notes.
|
||||
Copy the description from the pull request to the release.
|
||||
|
||||
### Update the Development Version
|
||||
|
||||
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.3.4, set:
|
||||
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.9.9, set:
|
||||
|
||||
```
|
||||
VERSION = 'v2.3.5-dev'
|
||||
VERSION = 'v2.9.10-dev'
|
||||
```
|
||||
|
||||
@@ -5,8 +5,8 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
|
||||
## PEP 8 Exceptions
|
||||
|
||||
* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
|
||||
* The library being import contains only constant declarations (`constants.py`)
|
||||
* The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
|
||||
* The library being import contains only constant declarations (e.g. `constants.py`)
|
||||
* The library being imported explicitly defines `__all__`
|
||||
|
||||
* Maximum line length is 120 characters (E501)
|
||||
* This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
|
||||
@@ -45,7 +45,7 @@ When adding a new dependency, a short description of the package and the URL of
|
||||
|
||||
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
|
||||
|
||||
* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
|
||||
* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
|
||||
|
||||
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@ The `users.UserConfig` model holds individual preferences for each user in the f
|
||||
| ---- | ----------- |
|
||||
| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) |
|
||||
| pagination.per_page | The number of items to display per page of a paginated table |
|
||||
| tables.${table_name}.columns | The ordered list of columns to display when viewing the table |
|
||||
| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table |
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Utility Views
|
||||
|
||||
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
|
||||
|
||||
## Individual Views
|
||||
|
||||
### ObjectView
|
||||
|
||||
Retrieve and display a single object.
|
||||
|
||||
### ObjectListView
|
||||
|
||||
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
|
||||
|
||||
### ObjectEditView
|
||||
|
||||
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
|
||||
|
||||
### ObjectDeleteView
|
||||
|
||||
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
|
||||
|
||||
## Bulk Views
|
||||
|
||||
### BulkCreateView
|
||||
|
||||
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
|
||||
|
||||
### BulkImportView
|
||||
|
||||
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
|
||||
|
||||
### BulkEditView
|
||||
|
||||
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
|
||||
|
||||
### BulkDeleteView
|
||||
|
||||
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
|
||||
|
||||
## Component Views
|
||||
|
||||
### ComponentCreateView
|
||||
|
||||
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
|
||||
|
||||
### ComponentEditView
|
||||
|
||||
A subclass of `ObjectEditView`: Updates an individual component object.
|
||||
|
||||
### ComponentDeleteView
|
||||
|
||||
A subclass of `ObjectDeleteView`: Deletes an individual component object.
|
||||
|
||||
### BulkComponentCreateView
|
||||
|
||||
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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 not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
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.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +1,69 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10-beta1 (2020-11-17)
|
||||
## 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.
|
||||
|
||||
@@ -74,12 +137,17 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
|
||||
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window
|
||||
* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`)
|
||||
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
|
||||
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
|
||||
* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy
|
||||
* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons
|
||||
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
|
||||
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates
|
||||
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
|
||||
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method
|
||||
* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models
|
||||
* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list
|
||||
@@ -89,6 +157,8 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
|
||||
|
||||
* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete)
|
||||
* Added the `/extras/content-types/` endpoint for Django ContentTypes
|
||||
* Added the `/extras/custom-fields/` endpoint for custom fields
|
||||
* Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint)
|
||||
* Added the `/status/` endpoint to convey NetBox's current status
|
||||
* circuits.CircuitTermination:
|
||||
* Added the `/trace/` endpoint
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.10 (FUTURE)
|
||||
## v2.9.11 (2020-12-11)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command`
|
||||
* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API
|
||||
* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names
|
||||
* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned
|
||||
* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list
|
||||
* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext
|
||||
* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms
|
||||
* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported)
|
||||
* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list
|
||||
* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings
|
||||
|
||||
---
|
||||
|
||||
## v2.9.10 (2020-11-24)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5319](https://github.com/netbox-community/netbox/issues/5319) - Add USB types for power ports and outlets
|
||||
* [#5337](https://github.com/netbox-community/netbox/issues/5337) - Add "splice" type for pass-through ports
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5235](https://github.com/netbox-community/netbox/issues/5235) - Fix exception when editing IP address with a NAT IP assigned to a non-racked device
|
||||
* [#5309](https://github.com/netbox-community/netbox/issues/5309) - Avoid extraneous database queries when manipulating objects
|
||||
* [#5345](https://github.com/netbox-community/netbox/issues/5345) - Fix non-deterministic ordering of prefixes and IP addresses
|
||||
* [#5350](https://github.com/netbox-community/netbox/issues/5350) - Filter available racks by selected group when creating a rack reservation
|
||||
* [#5355](https://github.com/netbox-community/netbox/issues/5355) - Limit rack groups by selected site when editing a rack
|
||||
* [#5356](https://github.com/netbox-community/netbox/issues/5356) - Populate manufacturer field when adding a device component template
|
||||
* [#5360](https://github.com/netbox-community/netbox/issues/5360) - Clear VLAN assignments when setting interface mode to none
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,8 +68,8 @@ nav:
|
||||
- Working with Secrets: 'rest-api/working-with-secrets.md'
|
||||
- Development:
|
||||
- Introduction: 'development/index.md'
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
- Style Guide: 'development/style-guide.md'
|
||||
- Utility Views: 'development/utility-views.md'
|
||||
- Extending Models: 'development/extending-models.md'
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Prefetch
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits import filters
|
||||
@@ -6,6 +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 count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -23,8 +24,8 @@ class CircuitsRootView(APIRootView):
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=Count('circuits')
|
||||
).order_by(*Provider._meta.ordering)
|
||||
circuit_count=count_related(Circuit, 'provider')
|
||||
)
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
|
||||
@@ -35,8 +36,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=Count('circuits')
|
||||
).order_by(*CircuitType._meta.ordering)
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilterSet
|
||||
|
||||
@@ -64,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filters.CircuitTerminationFilterSet
|
||||
brief_prefetch_fields = ['circuit']
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.db.models import Q
|
||||
|
||||
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
|
||||
from dcim.models import Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -73,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
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 count_related
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -17,7 +17,9 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
#
|
||||
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
table = tables.ProviderTable
|
||||
@@ -26,11 +28,9 @@ class ProviderListView(generic.ObjectListView):
|
||||
class ProviderView(generic.ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
provider = get_object_or_404(self.queryset, slug=slug)
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=provider
|
||||
provider=instance
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
).annotate_sites()
|
||||
@@ -44,10 +44,9 @@ class ProviderView(generic.ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
return {
|
||||
'circuits_table': circuits_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class ProviderEditView(generic.ObjectEditView):
|
||||
@@ -67,14 +66,18 @@ class ProviderBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class ProviderBulkEditView(generic.BulkEditView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
form = forms.ProviderBulkEditForm
|
||||
|
||||
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
|
||||
@@ -84,7 +87,9 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class CircuitTypeListView(generic.ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
@@ -104,7 +109,9 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
@@ -124,30 +131,30 @@ class CircuitListView(generic.ObjectListView):
|
||||
class CircuitView(generic.ObjectView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# A-side termination
|
||||
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
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
|
||||
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
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 render(request, 'circuits/circuit.html', {
|
||||
'circuit': circuit,
|
||||
return {
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class CircuitEditView(generic.ObjectEditView):
|
||||
|
||||
@@ -584,22 +584,15 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
|
||||
'count_ipaddresses',
|
||||
]
|
||||
|
||||
# TODO: This validation should be handled by Interface.clean()
|
||||
def validate(self, data):
|
||||
|
||||
# All associated VLANs be global or assigned to the parent device's site.
|
||||
# Validate many-to-many VLAN assignments
|
||||
device = self.instance.device if self.instance else data.get('device')
|
||||
untagged_vlan = data.get('untagged_vlan')
|
||||
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
|
||||
"global.".format(untagged_vlan)
|
||||
})
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||
"be global.".format(vlan)
|
||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
|
||||
f"it must be global."
|
||||
})
|
||||
|
||||
return super().validate(data)
|
||||
@@ -773,11 +766,10 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for node in obj.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
ct = ContentType.objects.get_for_id(ct_id)
|
||||
# TODO: Return the object URL
|
||||
ret.append(f'{ct.app_label}.{ct.model}:{object_id}')
|
||||
for node in obj.get_path():
|
||||
serializer = get_serializer_for_model(node, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(node, context=context).data)
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, F
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
@@ -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,13 +119,13 @@ 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'),
|
||||
).order_by(*Site._meta.ordering)
|
||||
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,8 +152,8 @@ class RackGroupViewSet(ModelViewSet):
|
||||
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=Count('racks')
|
||||
).order_by(*RackRole._meta.ordering)
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilterSet
|
||||
|
||||
@@ -166,9 +166,9 @@ 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')
|
||||
).order_by(*Rack._meta.ordering)
|
||||
device_count=count_related(Device, 'rack'),
|
||||
powerfeed_count=count_related(PowerFeed, 'rack')
|
||||
)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilterSet
|
||||
|
||||
@@ -240,10 +240,10 @@ 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')
|
||||
).order_by(*Manufacturer._meta.ordering)
|
||||
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=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
device_count=count_related(Device, 'device_type')
|
||||
)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
brief_prefetch_fields = ['manufacturer']
|
||||
|
||||
|
||||
#
|
||||
@@ -318,9 +319,9 @@ class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'role')
|
||||
).order_by(*DeviceRole._meta.ordering)
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilterSet
|
||||
|
||||
@@ -331,9 +332,9 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
|
||||
).order_by(*Platform._meta.ordering)
|
||||
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=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering)
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
brief_prefetch_fields = ['master']
|
||||
|
||||
|
||||
#
|
||||
@@ -610,8 +621,8 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilterSet
|
||||
|
||||
|
||||
@@ -304,6 +304,16 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_ITA_M = 'ita-m'
|
||||
TYPE_ITA_N = 'ita-n'
|
||||
TYPE_ITA_O = 'ita-o'
|
||||
# USB
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_B = 'usb-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
TYPE_USB_MINI_A = 'usb-mini-a'
|
||||
TYPE_USB_MINI_B = 'usb-mini-b'
|
||||
TYPE_USB_MICRO_A = 'usb-micro-a'
|
||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||
TYPE_USB_3_B = 'usb-3-b'
|
||||
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -393,6 +403,17 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_ITA_N, 'ITA Type N'),
|
||||
(TYPE_ITA_O, 'ITA Type O'),
|
||||
)),
|
||||
('USB', (
|
||||
(TYPE_USB_A, 'USB Type A'),
|
||||
(TYPE_USB_B, 'USB Type B'),
|
||||
(TYPE_USB_C, 'USB Type C'),
|
||||
(TYPE_USB_MINI_A, 'USB Mini A'),
|
||||
(TYPE_USB_MINI_B, 'USB Mini B'),
|
||||
(TYPE_USB_MICRO_A, 'USB Micro A'),
|
||||
(TYPE_USB_MICRO_B, 'USB Micro B'),
|
||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -482,6 +503,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_ITA_M = 'ita-m'
|
||||
TYPE_ITA_N = 'ita-n'
|
||||
TYPE_ITA_O = 'ita-o'
|
||||
# USB
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_MICROB = 'usb-micro-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
# Proprietary
|
||||
TYPE_HDOT_CX = 'hdot-cx'
|
||||
|
||||
@@ -572,6 +597,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_ITA_N, 'ITA Type N'),
|
||||
(TYPE_ITA_O, 'ITA Type O'),
|
||||
)),
|
||||
('USB', (
|
||||
(TYPE_USB_A, 'USB Type A'),
|
||||
(TYPE_USB_MICROB, 'USB Micro B'),
|
||||
(TYPE_USB_C, 'USB Type C'),
|
||||
)),
|
||||
('Proprietary', (
|
||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||
)),
|
||||
@@ -857,6 +887,8 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_LSH = 'lsh'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
TYPE_SN = 'sn'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@@ -884,6 +916,8 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_SC, 'SC'),
|
||||
(TYPE_SC_APC, 'SC/APC'),
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_CS, 'CS'),
|
||||
(TYPE_SN, 'SN'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count
|
||||
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ColorChoices
|
||||
@@ -80,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -179,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
|
||||
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, CustomFieldFilterSet, Creat
|
||||
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):
|
||||
@@ -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)',
|
||||
@@ -325,7 +331,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -504,7 +510,7 @@ class DeviceFilterSet(
|
||||
BaseFilterSet,
|
||||
TenancyFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
CustomFieldFilterSet,
|
||||
CustomFieldModelFilterSet,
|
||||
CreatedUpdatedFilterSet
|
||||
):
|
||||
q = django_filters.CharFilter(
|
||||
@@ -1246,7 +1252,7 @@ class PowerFeedFilterSet(
|
||||
BaseFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet,
|
||||
CustomFieldFilterSet,
|
||||
CustomFieldModelFilterSet,
|
||||
CreatedUpdatedFilterSet
|
||||
):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -21,10 +21,11 @@ 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,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK,
|
||||
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
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,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
@@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCommonForm:
|
||||
class InterfaceCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
@@ -109,13 +109,13 @@ class InterfaceCommonForm:
|
||||
|
||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
|
||||
valid_sites = [None, self.cleaned_data['device'].site]
|
||||
valid_sites = [None, self.cleaned_data[parent_field].site]
|
||||
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
||||
|
||||
if invalid_vlans:
|
||||
raise forms.ValidationError({
|
||||
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or they must be global".format(', '.join(invalid_vlans))
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
@@ -134,6 +134,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']:
|
||||
@@ -470,6 +471,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
queryset=RackRole.objects.all(),
|
||||
required=False
|
||||
@@ -683,6 +691,16 @@ 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',
|
||||
@@ -766,7 +784,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class RackReservationCSVForm(CSVModelForm):
|
||||
class RackReservationCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -816,7 +834,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()
|
||||
@@ -843,7 +861,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'
|
||||
@@ -867,6 +885,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)
|
||||
|
||||
|
||||
@@ -915,7 +942,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'front_image', 'rear_image', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'subdevice_role': StaticSelect2()
|
||||
'subdevice_role': StaticSelect2(),
|
||||
# Exclude SVG images (unsupported by PIL)
|
||||
'front_image': forms.FileInput(attrs={
|
||||
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
|
||||
}),
|
||||
'rear_image': forms.FileInput(attrs={
|
||||
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1027,7 +1061,10 @@ class ComponentTemplateCreateForm(ComponentForm):
|
||||
"""
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
initial_params={
|
||||
'device_types': 'device_type'
|
||||
}
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -1402,6 +1439,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'])
|
||||
@@ -1745,9 +1783,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/',
|
||||
@@ -1820,6 +1857,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
"config context",
|
||||
}
|
||||
widgets = {
|
||||
'face': StaticSelect2(),
|
||||
'status': StaticSelect2(),
|
||||
'primary_ip4': StaticSelect2(),
|
||||
'primary_ip6': StaticSelect2(),
|
||||
@@ -1866,6 +1904,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
|
||||
@@ -1875,31 +1920,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):
|
||||
@@ -2671,7 +2694,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@@ -2822,7 +2845,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,
|
||||
@@ -2838,6 +2861,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,
|
||||
@@ -2899,6 +2931,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']:
|
||||
@@ -3047,6 +3080,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'])
|
||||
@@ -3741,17 +3775,16 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class CableCSVForm(CSVModelForm):
|
||||
class CableCSVForm(CustomFieldModelCSVForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Side A device'
|
||||
)
|
||||
side_a_type = CSVModelChoiceField(
|
||||
side_a_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side A type'
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
@@ -3764,10 +3797,9 @@ class CableCSVForm(CSVModelForm):
|
||||
to_field_name='name',
|
||||
help_text='Side B device'
|
||||
)
|
||||
side_b_type = CSVModelChoiceField(
|
||||
side_b_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side B type'
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
@@ -3801,58 +3833,36 @@ class CableCSVForm(CSVModelForm):
|
||||
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
||||
}
|
||||
|
||||
# TODO: Merge the clean() methods for either end
|
||||
def clean_side_a_name(self):
|
||||
def _clean_side(self, side):
|
||||
"""
|
||||
Derive a Cable's A/B termination objects.
|
||||
|
||||
device = self.cleaned_data.get('side_a_device')
|
||||
content_type = self.cleaned_data.get('side_a_type')
|
||||
name = self.cleaned_data.get('side_a_name')
|
||||
:param side: 'a' or 'b'
|
||||
"""
|
||||
assert side in 'ab', f"Invalid side designation: {side}"
|
||||
|
||||
device = self.cleaned_data.get(f'side_{side}_device')
|
||||
content_type = self.cleaned_data.get(f'side_{side}_type')
|
||||
name = self.cleaned_data.get(f'side_{side}_name')
|
||||
if not device or not content_type or not name:
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
termination_object = model.objects.get(
|
||||
device=device,
|
||||
name=name
|
||||
)
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(
|
||||
"Side A: {} {} is already connected".format(device, termination_object)
|
||||
)
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
"A side termination not found: {} {}".format(device, name)
|
||||
)
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
self.instance.termination_a = termination_object
|
||||
setattr(self.instance, f'termination_{side}', termination_object)
|
||||
return termination_object
|
||||
|
||||
def clean_side_a_name(self):
|
||||
return self._clean_side('a')
|
||||
|
||||
def clean_side_b_name(self):
|
||||
|
||||
device = self.cleaned_data.get('side_b_device')
|
||||
content_type = self.cleaned_data.get('side_b_type')
|
||||
name = self.cleaned_data.get('side_b_name')
|
||||
if not device or not content_type or not name:
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
termination_object = model.objects.get(
|
||||
device=device,
|
||||
name=name
|
||||
)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(
|
||||
"Side B: {} {} is already connected".format(device, termination_object)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
"B side termination not found: {} {}".format(device, name)
|
||||
)
|
||||
|
||||
self.instance.termination_b = termination_object
|
||||
return termination_object
|
||||
return self._clean_side('b')
|
||||
|
||||
def clean_length_unit(self):
|
||||
# Avoid trying to save as NULL
|
||||
@@ -3860,7 +3870,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
|
||||
@@ -3903,6 +3913,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate length/unit
|
||||
length = self.cleaned_data.get('length')
|
||||
@@ -4246,7 +4257,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()
|
||||
@@ -4260,7 +4271,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',
|
||||
@@ -4347,7 +4358,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class PowerPanelCSVForm(CSVModelForm):
|
||||
class PowerPanelCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -4373,7 +4384,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
|
||||
@@ -4401,9 +4412,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
'rack_group',
|
||||
)
|
||||
nullable_fields = ['rack_group']
|
||||
|
||||
|
||||
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
|
||||
@@ -30,6 +30,13 @@ class Command(BaseCommand):
|
||||
help="Do not prompt user for any input/confirmation"
|
||||
)
|
||||
|
||||
def draw_progress_bar(self, percentage):
|
||||
"""
|
||||
Draw a simple progress bar 20 increments wide illustrating the specified percentage.
|
||||
"""
|
||||
bar_size = int(percentage / 5)
|
||||
self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20-bar_size)}] {int(percentage)}%", ending='')
|
||||
|
||||
def handle(self, *model_names, **options):
|
||||
|
||||
# If --force was passed, first delete all existing CablePaths
|
||||
@@ -67,15 +74,15 @@ class Command(BaseCommand):
|
||||
origins = origins.filter(_path__isnull=True)
|
||||
origins_count = origins.count()
|
||||
if not origins_count:
|
||||
print(f'Found no missing {model._meta.verbose_name} paths; skipping')
|
||||
self.stdout.write(f'Found no missing {model._meta.verbose_name} paths; skipping')
|
||||
continue
|
||||
print(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
||||
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
||||
i = 0
|
||||
for i, obj in enumerate(origins, start=1):
|
||||
create_cablepath(obj)
|
||||
# TODO: Come up with a better progress indicator
|
||||
if not i % 1000:
|
||||
self.stdout.write(f' {i}')
|
||||
self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}'))
|
||||
if not i % 100:
|
||||
self.draw_progress_bar(i * 100 / origins_count)
|
||||
self.draw_progress_bar(100)
|
||||
self.stdout.write(self.style.SUCCESS(f'\n Retraced {i} {model._meta.verbose_name_plural}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Finished.'))
|
||||
|
||||
@@ -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])
|
||||
@@ -411,21 +412,27 @@ class CablePath(models.Model):
|
||||
position_stack.append(peer_termination.rear_port_position)
|
||||
path.append(object_to_path_node(node))
|
||||
|
||||
# Follow a RearPort to its corresponding FrontPort
|
||||
# Follow a RearPort to its corresponding FrontPort (if any)
|
||||
elif isinstance(peer_termination, RearPort):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
|
||||
# Determine the peer FrontPort's position
|
||||
if peer_termination.positions == 1:
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1)
|
||||
path.append(object_to_path_node(node))
|
||||
position = 1
|
||||
elif position_stack:
|
||||
position = position_stack.pop()
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
|
||||
path.append(object_to_path_node(node))
|
||||
else:
|
||||
# No position indicated: path has split, so we stop at the RearPort
|
||||
is_split = True
|
||||
break
|
||||
|
||||
try:
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
|
||||
path.append(object_to_path_node(node))
|
||||
except ObjectDoesNotExist:
|
||||
# No corresponding FrontPort found for the RearPort
|
||||
break
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
destination = peer_termination
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -464,6 +466,18 @@ class BaseInterface(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
if not self.mode:
|
||||
self.untagged_vlan = None
|
||||
|
||||
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
|
||||
self.tagged_vlans.clear()
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
@@ -491,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(
|
||||
@@ -543,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 (
|
||||
@@ -580,18 +595,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
"device, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
if self.mode is None:
|
||||
self.untagged_vlan = None
|
||||
|
||||
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
|
||||
self.tagged_vlans.clear()
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.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:
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -326,22 +327,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):
|
||||
"""
|
||||
|
||||
@@ -447,7 +447,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'
|
||||
|
||||
@@ -45,19 +45,22 @@ INTERFACE_IPADDRESSES = """
|
||||
"""
|
||||
|
||||
INTERFACE_TAGGED_VLANS = """
|
||||
{% for vlan in record.tagged_vlans.unrestricted %}
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||
{% endfor %}
|
||||
{% if record.mode == 'tagged' %}
|
||||
{% for vlan in record.tagged_vlans.all %}
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||
{% endfor %}
|
||||
{% elif record.mode == 'tagged-all' %}
|
||||
All
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
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 = """
|
||||
@@ -100,9 +103,9 @@ CONSOLEPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -120,9 +123,9 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -140,8 +143,8 @@ POWERPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=device.pk %}">Power Outlet</a></li>
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=device.pk %}">Power Feed</a></li>
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -154,7 +157,7 @@ POWEROUTLET_BUTTONS = """
|
||||
{% 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>
|
||||
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=device.pk %}" title="Connect" class="btn btn-success btn-xs">
|
||||
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -162,7 +165,7 @@ POWEROUTLET_BUTTONS = """
|
||||
|
||||
INTERFACE_BUTTONS = """
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=device.pk %}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -177,10 +180,10 @@ INTERFACE_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Circuit Termination</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -198,12 +201,12 @@ FRONTPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Circuit Termination</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -221,10 +224,10 @@ REARPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Circuit Termination</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -233,11 +236,11 @@ REARPORT_BUTTONS = """
|
||||
DEVICEBAY_BUTTONS = """
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
{% if record.installed_device %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove device"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=device.pk %}" class="btn btn-success btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-xs">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -796,6 +796,30 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
def test_207_rearport_without_frontport(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
|
||||
# Create cables
|
||||
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
|
||||
cable1.save()
|
||||
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
|
||||
cable2.save()
|
||||
self.assertPathExists(
|
||||
origin=interface1,
|
||||
destination=None,
|
||||
path=(cable1, frontport1, rearport1, cable2, rearport2),
|
||||
is_active=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -997,6 +1005,130 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_consoleports(self):
|
||||
device = Device.objects.first()
|
||||
console_ports = (
|
||||
ConsolePort(device=device, name='Console Port 1'),
|
||||
ConsolePort(device=device, name='Console Port 2'),
|
||||
ConsolePort(device=device, name='Console Port 3'),
|
||||
)
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
url = reverse('dcim:device_consoleports', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_consoleserverports(self):
|
||||
device = Device.objects.first()
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(device=device, name='Console Server Port 1'),
|
||||
ConsoleServerPort(device=device, name='Console Server Port 2'),
|
||||
ConsoleServerPort(device=device, name='Console Server Port 3'),
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
url = reverse('dcim:device_consoleserverports', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_powerports(self):
|
||||
device = Device.objects.first()
|
||||
power_ports = (
|
||||
PowerPort(device=device, name='Power Port 1'),
|
||||
PowerPort(device=device, name='Power Port 2'),
|
||||
PowerPort(device=device, name='Power Port 3'),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
url = reverse('dcim:device_powerports', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_poweroutlets(self):
|
||||
device = Device.objects.first()
|
||||
power_outlets = (
|
||||
PowerOutlet(device=device, name='Power Outlet 1'),
|
||||
PowerOutlet(device=device, name='Power Outlet 2'),
|
||||
PowerOutlet(device=device, name='Power Outlet 3'),
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
|
||||
url = reverse('dcim:device_poweroutlets', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_interfaces(self):
|
||||
device = Device.objects.first()
|
||||
interfaces = (
|
||||
Interface(device=device, name='Interface 1'),
|
||||
Interface(device=device, name='Interface 2'),
|
||||
Interface(device=device, name='Interface 3'),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
url = reverse('dcim:device_interfaces', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_rearports(self):
|
||||
device = Device.objects.first()
|
||||
rear_ports = (
|
||||
RearPort(device=device, name='Rear Port 1'),
|
||||
RearPort(device=device, name='Rear Port 2'),
|
||||
RearPort(device=device, name='Rear Port 3'),
|
||||
)
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
url = reverse('dcim:device_rearports', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_frontports(self):
|
||||
device = Device.objects.first()
|
||||
rear_ports = (
|
||||
RearPort(device=device, name='Rear Port 1'),
|
||||
RearPort(device=device, name='Rear Port 2'),
|
||||
RearPort(device=device, name='Rear Port 3'),
|
||||
)
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
front_ports = (
|
||||
FrontPort(device=device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
|
||||
FrontPort(device=device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
|
||||
FrontPort(device=device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
|
||||
)
|
||||
FrontPort.objects.bulk_create(front_ports)
|
||||
|
||||
url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_devicebays(self):
|
||||
device = Device.objects.first()
|
||||
device_bays = (
|
||||
DeviceBay(device=device, name='Device Bay 1'),
|
||||
DeviceBay(device=device, name='Device Bay 2'),
|
||||
DeviceBay(device=device, name='Device Bay 3'),
|
||||
)
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
|
||||
url = reverse('dcim:device_devicebays', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_device_inventory(self):
|
||||
device = Device.objects.first()
|
||||
inventory_items = (
|
||||
InventoryItem(device=device, name='Inventory Item 1'),
|
||||
InventoryItem(device=device, name='Inventory Item 2'),
|
||||
InventoryItem(device=device, name='Inventory Item 3'),
|
||||
)
|
||||
for item in inventory_items:
|
||||
item.save()
|
||||
|
||||
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
@@ -1544,9 +1676,9 @@ class CableTestCase(
|
||||
|
||||
cls.csv_data = (
|
||||
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
|
||||
"Device 3,interface,Interface 1,Device 4,interface,Interface 1",
|
||||
"Device 3,interface,Interface 2,Device 4,interface,Interface 2",
|
||||
"Device 3,interface,Interface 3,Device 4,interface,Interface 3",
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -197,7 +197,7 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
|
||||
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
|
||||
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
|
||||
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
|
||||
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
|
||||
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F, Prefetch
|
||||
from django.db.models import F, Prefetch
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.html import escape
|
||||
@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit
|
||||
from extras.views import ObjectConfigContextView
|
||||
from extras.views import ObjectChangeLogView, ObjectConfigContextView
|
||||
from ipam.models import IPAddress, Prefix, Service, VLAN
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from netbox.views import generic
|
||||
@@ -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
|
||||
@@ -152,16 +152,14 @@ class SiteListView(generic.ObjectListView):
|
||||
class SiteView(generic.ObjectView):
|
||||
queryset = Site.objects.prefetch_related('region', 'tenant__group')
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
site = get_object_or_404(self.queryset, slug=slug)
|
||||
def get_extra_context(self, request, instance):
|
||||
stats = {
|
||||
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=site).count(),
|
||||
'device_count': Device.objects.restrict(request.user, 'view').filter(site=site).count(),
|
||||
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=site).count(),
|
||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=site).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
|
||||
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
|
||||
}
|
||||
rack_groups = RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
@@ -169,13 +167,12 @@ class SiteView(generic.ObjectView):
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=site)
|
||||
).restrict(request.user, 'view').filter(site=instance)
|
||||
|
||||
return render(request, 'dcim/site.html', {
|
||||
'site': site,
|
||||
return {
|
||||
'stats': stats,
|
||||
'rack_groups': rack_groups,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class SiteEditView(generic.ObjectEditView):
|
||||
@@ -256,7 +253,9 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class RackRoleListView(generic.ObjectListView):
|
||||
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -276,7 +275,9 @@ class RackRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -285,9 +286,11 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class RackListView(generic.ObjectListView):
|
||||
queryset = Rack.objects.annotate(
|
||||
device_count=Count('devices')
|
||||
).order_by(*Rack._meta.ordering)
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'devices__device_type'
|
||||
).annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filters.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
table = tables.RackDetailTable
|
||||
@@ -338,36 +341,37 @@ class RackElevationListView(generic.ObjectListView):
|
||||
class RackView(generic.ObjectView):
|
||||
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
|
||||
|
||||
def get(self, request, pk):
|
||||
rack = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get 0U and child devices located within the rack
|
||||
nonracked_devices = Device.objects.filter(
|
||||
rack=rack,
|
||||
rack=instance,
|
||||
position__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||
|
||||
if rack.group:
|
||||
peer_racks = peer_racks.filter(group=rack.group)
|
||||
if instance.group:
|
||||
peer_racks = peer_racks.filter(group=instance.group)
|
||||
else:
|
||||
peer_racks = peer_racks.filter(group__isnull=True)
|
||||
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
|
||||
next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
|
||||
|
||||
reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=rack)
|
||||
power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=rack).prefetch_related('power_panel')
|
||||
reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
|
||||
power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(
|
||||
'power_panel'
|
||||
)
|
||||
|
||||
return render(request, 'dcim/rack.html', {
|
||||
'rack': rack,
|
||||
'device_count': Device.objects.restrict(request.user, 'view').filter(rack=rack).count(),
|
||||
device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count()
|
||||
|
||||
return {
|
||||
'device_count': device_count,
|
||||
'reservations': reservations,
|
||||
'power_feeds': power_feeds,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class RackEditView(generic.ObjectEditView):
|
||||
@@ -413,14 +417,6 @@ class RackReservationListView(generic.ObjectListView):
|
||||
class RackReservationView(generic.ObjectView):
|
||||
queryset = RackReservation.objects.prefetch_related('rack')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
rackreservation = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'dcim/rackreservation.html', {
|
||||
'rackreservation': rackreservation,
|
||||
})
|
||||
|
||||
|
||||
class RackReservationEditView(generic.ObjectEditView):
|
||||
queryset = RackReservation.objects.all()
|
||||
@@ -474,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
|
||||
|
||||
@@ -498,8 +494,8 @@ class ManufacturerBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=Count('device_types')
|
||||
).order_by(*Manufacturer._meta.ordering)
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@@ -508,9 +504,9 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceTypeListView(generic.ObjectListView):
|
||||
queryset = DeviceType.objects.annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
filterset_form = forms.DeviceTypeFilterForm
|
||||
table = tables.DeviceTypeTable
|
||||
@@ -519,42 +515,40 @@ class DeviceTypeListView(generic.ObjectListView):
|
||||
class DeviceTypeView(generic.ObjectView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
devicetype = get_object_or_404(self.queryset, pk=pk)
|
||||
instance_count = Device.objects.restrict(request.user).filter(device_type=devicetype).count()
|
||||
def get_extra_context(self, request, instance):
|
||||
instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
|
||||
|
||||
# Component tables
|
||||
consoleport_table = tables.ConsolePortTemplateTable(
|
||||
ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
|
||||
ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
powerport_table = tables.PowerPortTemplateTable(
|
||||
PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
poweroutlet_table = tables.PowerOutletTemplateTable(
|
||||
PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
interface_table = tables.InterfaceTemplateTable(
|
||||
list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype)),
|
||||
list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=instance)),
|
||||
orderable=False
|
||||
)
|
||||
front_port_table = tables.FrontPortTemplateTable(
|
||||
FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
rear_port_table = tables.RearPortTemplateTable(
|
||||
RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
devicebay_table = tables.DeviceBayTemplateTable(
|
||||
DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
|
||||
DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
|
||||
orderable=False
|
||||
)
|
||||
if request.user.has_perm('dcim.change_devicetype'):
|
||||
@@ -567,8 +561,7 @@ class DeviceTypeView(generic.ObjectView):
|
||||
rear_port_table.columns.show('pk')
|
||||
devicebay_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/devicetype.html', {
|
||||
'devicetype': devicetype,
|
||||
return {
|
||||
'instance_count': instance_count,
|
||||
'consoleport_table': consoleport_table,
|
||||
'consoleserverport_table': consoleserverport_table,
|
||||
@@ -578,7 +571,7 @@ class DeviceTypeView(generic.ObjectView):
|
||||
'front_port_table': front_port_table,
|
||||
'rear_port_table': rear_port_table,
|
||||
'devicebay_table': devicebay_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceTypeEditView(generic.ObjectEditView):
|
||||
@@ -619,8 +612,8 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
|
||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
form = forms.DeviceTypeBulkEditForm
|
||||
@@ -628,8 +621,8 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
|
||||
@@ -920,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
|
||||
|
||||
@@ -952,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
|
||||
|
||||
@@ -995,50 +988,45 @@ class DeviceView(generic.ObjectView):
|
||||
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
|
||||
)
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# VirtualChassis members
|
||||
if device.virtual_chassis is not None:
|
||||
if instance.virtual_chassis is not None:
|
||||
vc_members = Device.objects.restrict(request.user, 'view').filter(
|
||||
virtual_chassis=device.virtual_chassis
|
||||
virtual_chassis=instance.virtual_chassis
|
||||
).order_by('vc_position')
|
||||
else:
|
||||
vc_members = []
|
||||
|
||||
# Services
|
||||
services = Service.objects.restrict(request.user, 'view').filter(device=device)
|
||||
services = Service.objects.restrict(request.user, 'view').filter(device=instance)
|
||||
|
||||
# Secrets
|
||||
secrets = Secret.objects.restrict(request.user, 'view').filter(device=device)
|
||||
secrets = Secret.objects.restrict(request.user, 'view').filter(device=instance)
|
||||
|
||||
# Find up to ten devices in the same site with the same functional role for quick reference.
|
||||
related_devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
site=device.site, device_role=device.device_role
|
||||
site=instance.site, device_role=instance.device_role
|
||||
).exclude(
|
||||
pk=device.pk
|
||||
pk=instance.pk
|
||||
).prefetch_related(
|
||||
'rack', 'device_type__manufacturer'
|
||||
)[:10]
|
||||
|
||||
return render(request, 'dcim/device/device.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'services': services,
|
||||
'secrets': secrets,
|
||||
'vc_members': vc_members,
|
||||
'related_devices': related_devices,
|
||||
'active_tab': 'device',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceConsolePortsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/consoleports.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
|
||||
'cable', '_path__destination',
|
||||
)
|
||||
consoleport_table = tables.DeviceConsolePortTable(
|
||||
@@ -1049,21 +1037,19 @@ class DeviceConsolePortsView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'):
|
||||
consoleport_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/consoleports.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'consoleport_table': consoleport_table,
|
||||
'active_tab': 'console-ports',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceConsoleServerPortsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/consoleserverports.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter(
|
||||
device=device
|
||||
device=instance
|
||||
).prefetch_related(
|
||||
'cable', '_path__destination',
|
||||
)
|
||||
@@ -1076,20 +1062,18 @@ class DeviceConsoleServerPortsView(generic.ObjectView):
|
||||
request.user.has_perm('dcim.delete_consoleserverport'):
|
||||
consoleserverport_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/consoleserverports.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'consoleserverport_table': consoleserverport_table,
|
||||
'active_tab': 'console-server-ports',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DevicePowerPortsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/powerports.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
|
||||
'cable', '_path__destination',
|
||||
)
|
||||
powerport_table = tables.DevicePowerPortTable(
|
||||
@@ -1100,20 +1084,18 @@ class DevicePowerPortsView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'):
|
||||
powerport_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/powerports.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'powerport_table': powerport_table,
|
||||
'active_tab': 'power-ports',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DevicePowerOutletsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/poweroutlets.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
|
||||
'cable', 'power_port', '_path__destination',
|
||||
)
|
||||
poweroutlet_table = tables.DevicePowerOutletTable(
|
||||
@@ -1124,20 +1106,18 @@ class DevicePowerOutletsView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'):
|
||||
poweroutlet_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/poweroutlets.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'poweroutlet_table': poweroutlet_table,
|
||||
'active_tab': 'power-outlets',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceInterfacesView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
|
||||
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
|
||||
'lag', 'cable', '_path__destination', 'tags',
|
||||
@@ -1150,20 +1130,18 @@ class DeviceInterfacesView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'):
|
||||
interface_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/interfaces.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'interface_table': interface_table,
|
||||
'active_tab': 'interfaces',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceFrontPortsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/frontports.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
|
||||
'rear_port', 'cable',
|
||||
)
|
||||
frontport_table = tables.DeviceFrontPortTable(
|
||||
@@ -1174,20 +1152,18 @@ class DeviceFrontPortsView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'):
|
||||
frontport_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/frontports.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'frontport_table': frontport_table,
|
||||
'active_tab': 'front-ports',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceRearPortsView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/rearports.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable')
|
||||
def get_extra_context(self, request, instance):
|
||||
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable')
|
||||
rearport_table = tables.DeviceRearPortTable(
|
||||
data=rearports,
|
||||
user=request.user,
|
||||
@@ -1196,20 +1172,18 @@ class DeviceRearPortsView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'):
|
||||
rearport_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/rearports.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'rearport_table': rearport_table,
|
||||
'active_tab': 'rear-ports',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceDeviceBaysView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
|
||||
'installed_device__device_type__manufacturer',
|
||||
)
|
||||
devicebay_table = tables.DeviceDeviceBayTable(
|
||||
@@ -1220,21 +1194,19 @@ class DeviceDeviceBaysView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'):
|
||||
devicebay_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/devicebays.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'devicebay_table': devicebay_table,
|
||||
'active_tab': 'device-bays',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceInventoryView(generic.ObjectView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/inventory.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter(
|
||||
device=device
|
||||
device=instance
|
||||
).prefetch_related('manufacturer')
|
||||
inventoryitem_table = tables.DeviceInventoryItemTable(
|
||||
data=inventoryitems,
|
||||
@@ -1244,56 +1216,50 @@ class DeviceInventoryView(generic.ObjectView):
|
||||
if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'):
|
||||
inventoryitem_table.columns.show('pk')
|
||||
|
||||
return render(request, 'dcim/device/inventory.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'inventoryitem_table': inventoryitem_table,
|
||||
'active_tab': 'inventory',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceStatusView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/status.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'dcim/device/status.html', {
|
||||
'device': device,
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'active_tab': 'status',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceLLDPNeighborsView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/lldp_neighbors.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related('_path__destination').exclude(
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
'_path__destination'
|
||||
).exclude(
|
||||
type__in=NONCONNECTABLE_IFACE_TYPES
|
||||
)
|
||||
|
||||
return render(request, 'dcim/device/lldp_neighbors.html', {
|
||||
'device': device,
|
||||
return {
|
||||
'interfaces': interfaces,
|
||||
'active_tab': 'lldp-neighbors',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceConfigView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/config.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'dcim/device/config.html', {
|
||||
'device': device,
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'active_tab': 'config',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class DeviceConfigContextView(ObjectConfigContextView):
|
||||
@@ -1301,6 +1267,10 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
|
||||
|
||||
class DeviceChangeLogView(ObjectChangeLogView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
|
||||
|
||||
class DeviceEditView(generic.ObjectEditView):
|
||||
queryset = Device.objects.all()
|
||||
model_form = forms.DeviceForm
|
||||
@@ -1364,6 +1334,11 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
class ConsolePortView(generic.ObjectView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_consoleports'
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
@@ -1424,6 +1399,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
class ConsoleServerPortView(generic.ObjectView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_consoleserverports'
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
@@ -1484,6 +1464,11 @@ class PowerPortListView(generic.ObjectListView):
|
||||
class PowerPortView(generic.ObjectView):
|
||||
queryset = PowerPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_powerports'
|
||||
}
|
||||
|
||||
|
||||
class PowerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPort.objects.all()
|
||||
@@ -1544,6 +1529,11 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
class PowerOutletView(generic.ObjectView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_poweroutlets'
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
@@ -1604,35 +1594,32 @@ class InterfaceListView(generic.ObjectListView):
|
||||
class InterfaceView(generic.ObjectView):
|
||||
queryset = Interface.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
interface = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get assigned IP addresses
|
||||
ipaddress_table = InterfaceIPAddressTable(
|
||||
data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
|
||||
data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
if interface.untagged_vlan is not None:
|
||||
vlans.append(interface.untagged_vlan)
|
||||
if instance.untagged_vlan is not None:
|
||||
vlans.append(instance.untagged_vlan)
|
||||
vlans[0].tagged = False
|
||||
for vlan in interface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
|
||||
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
|
||||
vlan.tagged = True
|
||||
vlans.append(vlan)
|
||||
vlan_table = InterfaceVLANTable(
|
||||
interface=interface,
|
||||
interface=instance,
|
||||
data=vlans,
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return render(request, 'dcim/interface.html', {
|
||||
'instance': interface,
|
||||
return {
|
||||
'ipaddress_table': ipaddress_table,
|
||||
'vlan_table': vlan_table,
|
||||
})
|
||||
'breadcrumb_url': 'dcim:device_interfaces'
|
||||
}
|
||||
|
||||
|
||||
class InterfaceCreateView(generic.ComponentCreateView):
|
||||
@@ -1694,6 +1681,11 @@ class FrontPortListView(generic.ObjectListView):
|
||||
class FrontPortView(generic.ObjectView):
|
||||
queryset = FrontPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_frontports'
|
||||
}
|
||||
|
||||
|
||||
class FrontPortCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPort.objects.all()
|
||||
@@ -1754,6 +1746,11 @@ class RearPortListView(generic.ObjectListView):
|
||||
class RearPortView(generic.ObjectView):
|
||||
queryset = RearPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_rearports'
|
||||
}
|
||||
|
||||
|
||||
class RearPortCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPort.objects.all()
|
||||
@@ -1814,6 +1811,11 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
class DeviceBayView(generic.ObjectView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_devicebays'
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
@@ -1935,6 +1937,11 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
class InventoryItemView(generic.ObjectView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_inventory'
|
||||
}
|
||||
|
||||
|
||||
class InventoryItemEditView(generic.ObjectEditView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
@@ -2093,20 +2100,13 @@ class CableListView(generic.ObjectListView):
|
||||
class CableView(generic.ObjectView):
|
||||
queryset = Cable.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
cable = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'dcim/cable.html', {
|
||||
'cable': cable,
|
||||
})
|
||||
|
||||
|
||||
class PathTraceView(generic.ObjectView):
|
||||
"""
|
||||
Trace a cable path beginning from the given path endpoint (origin).
|
||||
"""
|
||||
additional_permissions = ['dcim.view_cable']
|
||||
template_name = 'dcim/cable_trace.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
model = kwargs.pop('model')
|
||||
@@ -2114,17 +2114,16 @@ class PathTraceView(generic.ObjectView):
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, pk):
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
def get_extra_context(self, request, instance):
|
||||
related_paths = []
|
||||
|
||||
# If tracing a PathEndpoint, locate the CablePath (if one exists) by its origin
|
||||
if isinstance(obj, PathEndpoint):
|
||||
path = obj._path
|
||||
if isinstance(instance, PathEndpoint):
|
||||
path = instance._path
|
||||
|
||||
# Otherwise, find all CablePaths which traverse the specified object
|
||||
else:
|
||||
related_paths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin')
|
||||
related_paths = CablePath.objects.filter(path__contains=instance).prefetch_related('origin')
|
||||
# Check for specification of a particular path (when tracing pass-through ports)
|
||||
try:
|
||||
path_id = int(request.GET.get('cablepath_id'))
|
||||
@@ -2135,12 +2134,11 @@ class PathTraceView(generic.ObjectView):
|
||||
else:
|
||||
path = related_paths.first()
|
||||
|
||||
return render(request, 'dcim/cable_trace.html', {
|
||||
'obj': obj,
|
||||
return {
|
||||
'path': path,
|
||||
'related_paths': related_paths,
|
||||
'total_length': path.get_total_length() if path else None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class CableCreateView(generic.ObjectEditView):
|
||||
@@ -2336,9 +2334,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
#
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.annotate(
|
||||
member_count=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering)
|
||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
table = tables.VirtualChassisTable
|
||||
filterset = filters.VirtualChassisFilterSet
|
||||
filterset_form = forms.VirtualChassisFilterForm
|
||||
@@ -2347,14 +2345,12 @@ class VirtualChassisListView(generic.ObjectListView):
|
||||
class VirtualChassisView(generic.ObjectView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
virtualchassis = get_object_or_404(self.queryset, pk=pk)
|
||||
members = Device.objects.restrict(request.user).filter(virtual_chassis=virtualchassis)
|
||||
def get_extra_context(self, request, instance):
|
||||
members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
|
||||
|
||||
return render(request, 'dcim/virtualchassis.html', {
|
||||
'virtualchassis': virtualchassis,
|
||||
return {
|
||||
'members': members,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VirtualChassisCreateView(generic.ObjectEditView):
|
||||
@@ -2566,9 +2562,11 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class PowerPanelListView(generic.ObjectListView):
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
filterset_form = forms.PowerPanelFilterForm
|
||||
table = tables.PowerPanelTable
|
||||
@@ -2577,20 +2575,17 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
class PowerPanelView(generic.ObjectView):
|
||||
queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
powerpanel = get_object_or_404(self.queryset, pk=pk)
|
||||
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=powerpanel).prefetch_related('rack')
|
||||
def get_extra_context(self, request, instance):
|
||||
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
|
||||
powerfeed_table = tables.PowerFeedTable(
|
||||
data=power_feeds,
|
||||
orderable=False
|
||||
)
|
||||
powerfeed_table.exclude = ['power_panel']
|
||||
|
||||
return render(request, 'dcim/powerpanel.html', {
|
||||
'powerpanel': powerpanel,
|
||||
return {
|
||||
'powerfeed_table': powerfeed_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class PowerPanelEditView(generic.ObjectEditView):
|
||||
@@ -2620,8 +2615,8 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
rack_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
table = tables.PowerPanelTable
|
||||
|
||||
@@ -2640,14 +2635,6 @@ class PowerFeedListView(generic.ObjectListView):
|
||||
class PowerFeedView(generic.ObjectView):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
powerfeed = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'dcim/powerfeed.html', {
|
||||
'powerfeed': powerfeed,
|
||||
})
|
||||
|
||||
|
||||
class PowerFeedEditView(generic.ObjectEditView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
|
||||
@@ -2,7 +2,6 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from utilities.forms import LaxURLField
|
||||
from .choices import CustomFieldTypeChoices
|
||||
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
|
||||
|
||||
|
||||
@@ -30,8 +29,8 @@ class WebhookForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'obj_type' in self.fields:
|
||||
order_content_types(self.fields['obj_type'])
|
||||
if 'content_types' in self.fields:
|
||||
order_content_types(self.fields['content_types'])
|
||||
|
||||
|
||||
@admin.register(Webhook)
|
||||
@@ -41,12 +40,12 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
'ssl_verification',
|
||||
]
|
||||
list_filter = [
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
|
||||
]
|
||||
form = WebhookForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'obj_type', 'enabled')
|
||||
'fields': ('name', 'content_types', 'enabled')
|
||||
}),
|
||||
('Events', {
|
||||
'fields': ('type_create', 'type_update', 'type_delete')
|
||||
@@ -63,7 +62,7 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def models(self, obj):
|
||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||
return ', '.join([ct.name for ct in obj.content_types.all()])
|
||||
|
||||
|
||||
#
|
||||
@@ -76,6 +75,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
model = CustomField
|
||||
exclude = []
|
||||
widgets = {
|
||||
'default': forms.TextInput(),
|
||||
'validation_regex': forms.Textarea(
|
||||
attrs={
|
||||
'cols': 80,
|
||||
|
||||
@@ -26,15 +26,8 @@ class CustomFieldDefaultValues:
|
||||
# Populate the default value for each CustomField
|
||||
value = {}
|
||||
for field in fields:
|
||||
if field.default:
|
||||
if field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
field_value = int(field.default)
|
||||
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
# TODO: Fix default value assignment for boolean custom fields
|
||||
field_value = False if field.default.lower() == 'false' else bool(field.default)
|
||||
else:
|
||||
field_value = field.default
|
||||
value[field.name] = field_value
|
||||
if field.default is not None:
|
||||
value[field.name] = field.default
|
||||
else:
|
||||
value[field.name] = None
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from users.api.nested_serializers import NestedUserSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedImageAttachmentSerializer',
|
||||
'NestedJobResultSerializer',
|
||||
@@ -13,6 +14,14 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class NestedCustomFieldSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.CustomField
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedConfigContextSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from dcim.api.nested_serializers import (
|
||||
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.models import (
|
||||
ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
|
||||
ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
|
||||
)
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
|
||||
@@ -24,6 +24,27 @@ from virtualization.models import Cluster, ClusterGroup
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
|
||||
content_types = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
many=True
|
||||
)
|
||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Export templates
|
||||
#
|
||||
|
||||
@@ -5,6 +5,9 @@ from . import views
|
||||
router = OrderedDefaultRouter()
|
||||
router.APIRootView = views.ExtrasRootView
|
||||
|
||||
# Custom fields
|
||||
router.register('custom-fields', views.CustomFieldViewSet)
|
||||
|
||||
# Export templates
|
||||
router.register('export-templates', views.ExportTemplateViewSet)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
@@ -12,40 +11,20 @@ from rq import Worker
|
||||
|
||||
from extras import filters
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag
|
||||
from extras.models import (
|
||||
ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
|
||||
)
|
||||
from extras.models import CustomField
|
||||
from extras.reports import get_report, get_reports, run_report
|
||||
from extras.scripts import get_script, get_scripts, run_script
|
||||
from netbox.api.views import ModelViewSet
|
||||
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
|
||||
from utilities.utils import copy_safe_request, count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ConfigContextQuerySetMixin:
|
||||
"""
|
||||
Used by views that work with config context models (device and virtual machine).
|
||||
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
|
||||
|
||||
If the `brief` query param equates to True or the `exclude` query param
|
||||
includes `config_context` as a value, return the base queryset.
|
||||
|
||||
Else, return the queryset annotated with config context data
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class ExtrasRootView(APIRootView):
|
||||
"""
|
||||
Extras API root view
|
||||
@@ -54,10 +33,39 @@ class ExtrasRootView(APIRootView):
|
||||
return 'Extras'
|
||||
|
||||
|
||||
class ConfigContextQuerySetMixin:
|
||||
"""
|
||||
Used by views that work with config context models (device and virtual machine).
|
||||
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
|
||||
|
||||
If the `brief` query param equates to True or the `exclude` query param
|
||||
includes `config_context` as a value, return the base queryset.
|
||||
|
||||
Else, return the queryset annotated with config context data
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
request = self.get_serializer_context()['request']
|
||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
||||
return queryset
|
||||
return queryset.annotate_config_context_data()
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CustomField.objects.all()
|
||||
serializer_class = serializers.CustomFieldSerializer
|
||||
filterset_class = filters.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomFieldModelViewSet(ModelViewSet):
|
||||
"""
|
||||
Include the applicable set of CustomFields in the ModelViewSet context.
|
||||
@@ -93,8 +101,8 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
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
|
||||
@@ -16,7 +17,7 @@ __all__ = (
|
||||
'ContentTypeFilterSet',
|
||||
'CreatedUpdatedFilterSet',
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldFilterSet',
|
||||
'CustomFieldModelFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
@@ -38,27 +39,24 @@ 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 CustomFieldFilterSet(django_filters.FilterSet):
|
||||
class CustomFieldModelFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
Dynamically add a Filter for each CustomField applicable to the parent model.
|
||||
"""
|
||||
@@ -74,6 +72,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
|
||||
|
||||
class ExportTemplateFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -25,12 +25,18 @@ class Command(BaseCommand):
|
||||
help = "Start the Django shell with all NetBox models already imported"
|
||||
django_models = {}
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'-c', '--command',
|
||||
help='Python code to execute (instead of starting an interactive shell)',
|
||||
)
|
||||
|
||||
def _lsmodels(self):
|
||||
for app, models in self.django_models.items():
|
||||
app_name = apps.get_app_config(app).verbose_name
|
||||
print('{}:'.format(app_name))
|
||||
print(f'{app_name}:')
|
||||
for m in models:
|
||||
print(' {}'.format(m))
|
||||
print(f' {m}')
|
||||
|
||||
def get_namespace(self):
|
||||
namespace = {}
|
||||
@@ -46,7 +52,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Constants
|
||||
try:
|
||||
app_constants = sys.modules['{}.constants'.format(app)]
|
||||
app_constants = sys.modules[f'{app}.constants']
|
||||
for name in dir(app_constants):
|
||||
namespace[name] = getattr(app_constants, name)
|
||||
except KeyError:
|
||||
@@ -64,5 +70,10 @@ class Command(BaseCommand):
|
||||
return namespace
|
||||
|
||||
def handle(self, **options):
|
||||
# If Python code has been passed, execute it and exit.
|
||||
if options['command']:
|
||||
exec(options['command'], self.get_namespace())
|
||||
return
|
||||
|
||||
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
|
||||
return shell
|
||||
|
||||
@@ -34,6 +34,12 @@ class Migration(migrations.Migration):
|
||||
size=None
|
||||
),
|
||||
),
|
||||
# Introduce new default field (to be renamed later)
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='default2',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
# Rename obj_type to content_types
|
||||
migrations.RenameField(
|
||||
model_name='customfield',
|
||||
|
||||
@@ -16,6 +16,28 @@ def deserialize_value(field, value):
|
||||
return value
|
||||
|
||||
|
||||
def migrate_customfield_defaults(apps, schema_editor):
|
||||
"""
|
||||
Copy old serialized defaults to native JSON types.
|
||||
"""
|
||||
CustomField = apps.get_model('extras', 'CustomField')
|
||||
|
||||
for customfield in CustomField.objects.exclude(default=''):
|
||||
try:
|
||||
if customfield.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
value = int(customfield.default)
|
||||
elif customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
value = customfield.default in ['true', 'yes', '1']
|
||||
else:
|
||||
value = customfield.default
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f'Invalid default value "{customfield.default}" found for {customfield.type} '
|
||||
f'custom field {customfield.name}'
|
||||
)
|
||||
CustomField.objects.filter(pk=customfield.pk).update(default2=value)
|
||||
|
||||
|
||||
def migrate_customfieldchoices(apps, schema_editor):
|
||||
"""
|
||||
Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
|
||||
@@ -45,12 +67,21 @@ 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)
|
||||
|
||||
|
||||
def fix_filter_logic_values(apps, schema_editor):
|
||||
"""
|
||||
Fix invalid values for CustomField.filter_logic (see #5376)
|
||||
"""
|
||||
CustomField = apps.get_model('extras', 'CustomField')
|
||||
|
||||
CustomField.objects.filter(filter_logic="integer").update(filter_logic="loose")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -64,10 +95,16 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=migrate_customfield_defaults
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=migrate_customfieldchoices
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=migrate_customfieldvalues
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=fix_filter_logic_values
|
||||
),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,15 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='CustomField',
|
||||
name='default',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='CustomField',
|
||||
old_name='default2',
|
||||
new_name='default'
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='CustomFieldChoice',
|
||||
),
|
||||
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal file
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1 on 2020-12-02 19:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0052_customfield_cleanup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='webhook',
|
||||
old_name='obj_type',
|
||||
new_name='content_types',
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -13,6 +13,7 @@ from django.utils.safestring import mark_safe
|
||||
from extras.choices import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
|
||||
@@ -46,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
|
||||
@@ -63,7 +66,7 @@ class CustomFieldModel(models.Model):
|
||||
raise ValidationError(f"Missing required custom field '{cf.name}'.")
|
||||
|
||||
|
||||
class CustomFieldManager(models.Manager):
|
||||
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
use_in_migrations = True
|
||||
|
||||
def get_for_model(self, model):
|
||||
@@ -114,10 +117,11 @@ class CustomField(models.Model):
|
||||
help_text='Loose matches any instance of a given string; exact '
|
||||
'matches the entire field.'
|
||||
)
|
||||
default = models.CharField(
|
||||
max_length=100,
|
||||
default = models.JSONField(
|
||||
blank=True,
|
||||
help_text='Default value for the field. Use "true" or "false" for booleans.'
|
||||
null=True,
|
||||
help_text='Default value for the field (must be a JSON value). Encapsulate '
|
||||
'strings with double quotes (e.g. "Foo").'
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
@@ -170,6 +174,17 @@ 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:
|
||||
self.validate(self.default)
|
||||
except ValidationError as err:
|
||||
raise ValidationError({
|
||||
'default': f'Invalid default value "{self.default}": {err.message}'
|
||||
})
|
||||
|
||||
# Minimum/maximum values can be set only for numeric fields
|
||||
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
raise ValidationError({
|
||||
@@ -181,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"
|
||||
})
|
||||
@@ -193,7 +209,7 @@ class CustomField(models.Model):
|
||||
})
|
||||
|
||||
# A selection field must have at least two choices defined
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and len(self.choices) < 2:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
|
||||
raise ValidationError({
|
||||
'choices': "Selection fields must specify at least two choices."
|
||||
})
|
||||
@@ -231,8 +247,6 @@ class CustomField(models.Model):
|
||||
(True, 'True'),
|
||||
(False, 'False'),
|
||||
)
|
||||
if initial is not None:
|
||||
initial = bool(initial)
|
||||
field = forms.NullBooleanField(
|
||||
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||
)
|
||||
@@ -308,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:
|
||||
|
||||
@@ -31,8 +31,7 @@ class Webhook(models.Model):
|
||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||
"""
|
||||
# TODO: Rename obj_type to content_types (see #4711)
|
||||
obj_type = models.ManyToManyField(
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='webhooks',
|
||||
verbose_name='Object types',
|
||||
@@ -118,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.'
|
||||
@@ -437,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:
|
||||
@@ -483,7 +487,6 @@ class ConfigContextModel(models.Model):
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
|
||||
@@ -3,7 +3,6 @@ import inspect
|
||||
from packaging import version
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import get_template
|
||||
|
||||
@@ -71,10 +70,10 @@ class PluginConfig(AppConfig):
|
||||
register_menu_items(self.verbose_name, menu_items)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, user_config):
|
||||
def validate(cls, user_config, netbox_version):
|
||||
|
||||
# Enforce version constraints
|
||||
current_version = version.parse(settings.VERSION)
|
||||
current_version = version.parse(netbox_version)
|
||||
if cls.min_version is not None:
|
||||
min_version = version.parse(cls.min_version)
|
||||
if current_version < min_version:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.db.models import OuterRef, Subquery, Q
|
||||
|
||||
from extras.models.tags import TaggedItem
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
@@ -81,11 +82,27 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
|
||||
def _get_config_context_filters(self):
|
||||
# Construct the set of Q objects for the specific object types
|
||||
tag_query_filters = {
|
||||
"object_id": OuterRef(OuterRef('pk')),
|
||||
"content_type__app_label": self.model._meta.app_label,
|
||||
"content_type__model": self.model._meta.model_name
|
||||
}
|
||||
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(tags=OuterRef('tags')) | Q(tags=None),
|
||||
Q(
|
||||
tags__pk__in=Subquery(
|
||||
TaggedItem.objects.filter(
|
||||
**tag_query_filters
|
||||
).values_list(
|
||||
'tag_id',
|
||||
flat=True
|
||||
)
|
||||
)
|
||||
) | Q(tags=None),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -96,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'
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from rq import Worker
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag
|
||||
from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag
|
||||
from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
@@ -30,6 +30,53 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CustomField
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
'name': 'cf4',
|
||||
'type': 'date',
|
||||
},
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
'name': 'cf5',
|
||||
'type': 'url',
|
||||
},
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
'name': 'cf6',
|
||||
'type': 'select',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
|
||||
custom_fields = (
|
||||
CustomField(
|
||||
name='cf1',
|
||||
type='text'
|
||||
),
|
||||
CustomField(
|
||||
name='cf2',
|
||||
type='integer'
|
||||
),
|
||||
CustomField(
|
||||
name='cf3',
|
||||
type='boolean'
|
||||
),
|
||||
)
|
||||
CustomField.objects.bulk_create(custom_fields)
|
||||
for cf in custom_fields:
|
||||
cf.content_types.add(site_ct)
|
||||
|
||||
|
||||
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ExportTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -321,3 +321,46 @@ class ConfigContextTest(TestCase):
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
|
||||
"""
|
||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||
This is combatted by by appending distinct() to the config context querysets. This test creates a config
|
||||
context assigned to two tags and ensures objects related by those same two tags result in only a single
|
||||
config context record being returned.
|
||||
|
||||
This test case is seperate from the above in that it deals with multiple config context objects in play.
|
||||
|
||||
See https://github.com/netbox-community/netbox/issues/5387
|
||||
"""
|
||||
tag_context_1 = ConfigContext.objects.create(
|
||||
name="tag-1",
|
||||
weight=100,
|
||||
data={
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context_1.tags.add(self.tag)
|
||||
tag_context_2 = ConfigContext.objects.create(
|
||||
name="tag-2",
|
||||
weight=100,
|
||||
data={
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context_2.tags.add(self.tag2)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 3",
|
||||
site=self.site,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
device.tags.add(self.tag2)
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
@@ -86,21 +86,19 @@ class PluginTest(TestCase):
|
||||
"""
|
||||
self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS)
|
||||
|
||||
@override_settings(VERSION='0.9')
|
||||
def test_min_version(self):
|
||||
"""
|
||||
Check enforcement of minimum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({})
|
||||
dummy_config.validate({}, '0.9')
|
||||
|
||||
@override_settings(VERSION='10.0')
|
||||
def test_max_version(self):
|
||||
"""
|
||||
Check enforcement of maximum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({})
|
||||
dummy_config.validate({}, '10.0')
|
||||
|
||||
def test_required_settings(self):
|
||||
"""
|
||||
@@ -110,11 +108,11 @@ class PluginTest(TestCase):
|
||||
required_settings = ['foo']
|
||||
|
||||
# Validation should pass when all required settings are present
|
||||
DummyConfigWithRequiredSettings.validate({'foo': True})
|
||||
DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION)
|
||||
|
||||
# Validation should fail when a required setting is missing
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
DummyConfigWithRequiredSettings.validate({})
|
||||
DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
|
||||
|
||||
def test_default_settings(self):
|
||||
"""
|
||||
@@ -127,10 +125,10 @@ class PluginTest(TestCase):
|
||||
|
||||
# Populate the default value if setting has not been specified
|
||||
user_config = {}
|
||||
DummyConfigWithDefaultSettings.validate(user_config)
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 123)
|
||||
|
||||
# Don't overwrite specified values
|
||||
user_config = {'bar': 456}
|
||||
DummyConfigWithDefaultSettings.validate(user_config)
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 456)
|
||||
|
||||
@@ -39,7 +39,7 @@ class WebhookTest(APITestCase):
|
||||
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
))
|
||||
for webhook in webhooks:
|
||||
webhook.obj_type.set([site_ct])
|
||||
webhook.content_types.set([site_ct])
|
||||
|
||||
def test_enqueue_webhook_create(self):
|
||||
# Create an object via the REST API
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import template
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.db.models import Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
@@ -9,17 +9,14 @@ from django_rq.queues import get_connection
|
||||
from django_tables2 import RequestConfig
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from netbox.views import generic
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import copy_safe_request, shallow_compare_dict
|
||||
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports, run_report
|
||||
from .scripts import get_scripts, run_script
|
||||
|
||||
@@ -30,8 +27,8 @@ from .scripts import get_scripts, run_script
|
||||
|
||||
class TagListView(generic.ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
filterset = filters.TagFilterSet
|
||||
filterset_form = forms.TagFilterForm
|
||||
table = tables.TagTable
|
||||
@@ -55,16 +52,16 @@ class TagBulkImportView(generic.BulkImportView):
|
||||
|
||||
class TagBulkEditView(generic.BulkEditView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
|
||||
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
@@ -83,21 +80,7 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
class ConfigContextView(generic.ObjectView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
# Extend queryset to prefetch related objects
|
||||
self.queryset = self.queryset.prefetch_related(
|
||||
Prefetch('regions', queryset=Region.objects.restrict(request.user)),
|
||||
Prefetch('sites', queryset=Site.objects.restrict(request.user)),
|
||||
Prefetch('roles', queryset=DeviceRole.objects.restrict(request.user)),
|
||||
Prefetch('platforms', queryset=Platform.objects.restrict(request.user)),
|
||||
Prefetch('clusters', queryset=Cluster.objects.restrict(request.user)),
|
||||
Prefetch('cluster_groups', queryset=ClusterGroup.objects.restrict(request.user)),
|
||||
Prefetch('tenants', queryset=Tenant.objects.restrict(request.user)),
|
||||
Prefetch('tenant_groups', queryset=TenantGroup.objects.restrict(request.user)),
|
||||
)
|
||||
|
||||
configcontext = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
format = request.GET.get('format')
|
||||
@@ -108,10 +91,9 @@ class ConfigContextView(generic.ObjectView):
|
||||
else:
|
||||
format = 'json'
|
||||
|
||||
return render(request, 'extras/configcontext.html', {
|
||||
'configcontext': configcontext,
|
||||
return {
|
||||
'format': format,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class ConfigContextEditView(generic.ObjectEditView):
|
||||
@@ -138,12 +120,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ObjectConfigContextView(generic.ObjectView):
|
||||
base_template = None
|
||||
template_name = 'extras/object_configcontext.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
|
||||
model_name = self.queryset.model._meta.model_name
|
||||
def get_extra_context(self, request, instance):
|
||||
source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance)
|
||||
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
@@ -155,15 +135,13 @@ class ObjectConfigContextView(generic.ObjectView):
|
||||
else:
|
||||
format = 'json'
|
||||
|
||||
return render(request, 'extras/object_configcontext.html', {
|
||||
model_name: obj,
|
||||
'obj': obj,
|
||||
'rendered_context': obj.get_config_context(),
|
||||
return {
|
||||
'rendered_context': instance.get_config_context(),
|
||||
'source_contexts': source_contexts,
|
||||
'format': format,
|
||||
'base_template': self.base_template,
|
||||
'active_tab': 'config-context',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
@@ -182,14 +160,11 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
class ObjectChangeView(generic.ObjectView):
|
||||
queryset = ObjectChange.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
objectchange = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
request_id=objectchange.request_id
|
||||
request_id=instance.request_id
|
||||
).exclude(
|
||||
pk=objectchange.pk
|
||||
pk=instance.pk
|
||||
)
|
||||
related_changes_table = tables.ObjectChangeTable(
|
||||
data=related_changes[:50],
|
||||
@@ -197,39 +172,41 @@ class ObjectChangeView(generic.ObjectView):
|
||||
)
|
||||
|
||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
changed_object_type=objectchange.changed_object_type,
|
||||
changed_object_id=objectchange.changed_object_id,
|
||||
changed_object_type=instance.changed_object_type,
|
||||
changed_object_id=instance.changed_object_id,
|
||||
)
|
||||
|
||||
next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first()
|
||||
prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first()
|
||||
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
|
||||
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
|
||||
|
||||
if prev_change:
|
||||
diff_added = shallow_compare_dict(
|
||||
prev_change.object_data,
|
||||
objectchange.object_data,
|
||||
instance.object_data,
|
||||
exclude=['last_updated'],
|
||||
)
|
||||
diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
|
||||
else:
|
||||
# No previous change; this is the initial change that added the object
|
||||
diff_added = diff_removed = objectchange.object_data
|
||||
diff_added = diff_removed = instance.object_data
|
||||
|
||||
return render(request, 'extras/objectchange.html', {
|
||||
'objectchange': objectchange,
|
||||
return {
|
||||
'diff_added': diff_added,
|
||||
'diff_removed': diff_removed,
|
||||
'next_change': next_change,
|
||||
'prev_change': prev_change,
|
||||
'related_changes_table': related_changes_table,
|
||||
'related_changes_count': related_changes.count()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class ObjectChangeLogView(View):
|
||||
"""
|
||||
Present a history of changes made to a particular object.
|
||||
|
||||
base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
|
||||
"""
|
||||
base_template = None
|
||||
|
||||
def get(self, request, model, **kwargs):
|
||||
|
||||
@@ -259,20 +236,20 @@ class ObjectChangeLogView(View):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(objectchanges_table)
|
||||
|
||||
# Check whether a header template exists for this model
|
||||
base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
|
||||
try:
|
||||
template.loader.get_template(base_template)
|
||||
object_var = model._meta.model_name
|
||||
except template.TemplateDoesNotExist:
|
||||
base_template = 'base.html'
|
||||
object_var = 'obj'
|
||||
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
|
||||
# fall back to using base.html.
|
||||
if self.base_template is None:
|
||||
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
|
||||
# TODO: This can be removed once an object view has been established for every model.
|
||||
try:
|
||||
template.loader.get_template(self.base_template)
|
||||
except template.TemplateDoesNotExist:
|
||||
self.base_template = 'base.html'
|
||||
|
||||
return render(request, 'extras/object_changelog.html', {
|
||||
object_var: obj,
|
||||
'instance': obj, # We'll eventually standardize on 'instance` for the object variable name
|
||||
'object': obj,
|
||||
'table': objectchanges_table,
|
||||
'base_template': base_template,
|
||||
'base_template': self.base_template,
|
||||
'active_tab': 'changelog',
|
||||
})
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
|
||||
from extras.models import Webhook
|
||||
from utilities.api import get_serializer_for_model
|
||||
from .choices import *
|
||||
from .utils import FeatureQuery
|
||||
from .models import Webhook
|
||||
from .registry import registry
|
||||
|
||||
|
||||
def generate_signature(request_body, secret):
|
||||
@@ -28,19 +28,20 @@ def enqueue_webhooks(instance, user, request_id, action):
|
||||
Find Webhook(s) assigned to this instance + action and enqueue them
|
||||
to be processed
|
||||
"""
|
||||
obj_type = ContentType.objects.get_for_model(instance.__class__)
|
||||
|
||||
webhook_models = ContentType.objects.filter(FeatureQuery('webhooks').get_query())
|
||||
if obj_type not in webhook_models:
|
||||
# Determine whether this type of object supports webhooks
|
||||
app_label = instance._meta.app_label
|
||||
model_name = instance._meta.model_name
|
||||
if model_name not in registry['model_features']['webhooks'].get(app_label, []):
|
||||
return
|
||||
|
||||
# Retrieve any applicable Webhooks
|
||||
content_type = ContentType.objects.get_for_model(instance)
|
||||
action_flag = {
|
||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||
}[action]
|
||||
webhooks = Webhook.objects.filter(obj_type=obj_type, enabled=True, **{action_flag: True})
|
||||
webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True})
|
||||
|
||||
if webhooks.exists():
|
||||
# Get the Model's API serializer class and serialize the object
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -13,7 +12,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
|
||||
|
||||
|
||||
@@ -33,9 +32,9 @@ 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')
|
||||
).order_by(*VRF._meta.ordering)
|
||||
ipaddress_count=count_related(IPAddress, 'vrf'),
|
||||
prefix_count=count_related(Prefix, 'vrf')
|
||||
)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilterSet
|
||||
|
||||
@@ -56,8 +55,8 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=Count('aggregates')
|
||||
).order_by(*RIR._meta.ordering)
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilterSet
|
||||
|
||||
@@ -78,9 +77,9 @@ class AggregateViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
).order_by(*Role._meta.ordering)
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilterSet
|
||||
|
||||
@@ -92,7 +91,7 @@ class RoleViewSet(ModelViewSet):
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.prefetch_related(
|
||||
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
|
||||
).order_by(*Prefix._meta.ordering)
|
||||
)
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
@@ -262,7 +261,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
).order_by(*IPAddress._meta.ordering)
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filters.IPAddressFilterSet
|
||||
|
||||
@@ -273,8 +272,8 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filters.VLANGroupFilterSet
|
||||
|
||||
@@ -287,8 +286,8 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'vlan')
|
||||
).order_by(*VLAN._meta.ordering)
|
||||
prefix_count=count_related(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.db.models import Q
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import Device, Interface, Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter,
|
||||
@@ -30,7 +30,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -73,7 +73,7 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique']
|
||||
|
||||
|
||||
class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -122,7 +122,7 @@ class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'is_private', 'description']
|
||||
|
||||
|
||||
class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -183,7 +183,7 @@ class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -355,7 +355,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -551,7 +551,7 @@ class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -774,6 +774,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'):
|
||||
@@ -856,6 +857,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=IPAddressStatusChoices,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
role = CSVChoiceField(
|
||||
@@ -888,7 +890,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = IPAddress.csv_headers
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
||||
'dns_name', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0010_custom_field_data'),
|
||||
('extras', '0052_delete_customfieldchoice_customfieldvalue'),
|
||||
('extras', '0052_customfield_cleanup'),
|
||||
('ipam', '0040_service_drop_port'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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 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>
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
@@ -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>
|
||||
@@ -272,7 +270,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
@@ -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 *
|
||||
@@ -28,26 +28,23 @@ class VRFListView(generic.ObjectListView):
|
||||
class VRFView(generic.ObjectView):
|
||||
queryset = VRF.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vrf = get_object_or_404(self.queryset, pk=pk)
|
||||
prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
|
||||
def get_extra_context(self, request, instance):
|
||||
prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count()
|
||||
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
vrf.import_targets.prefetch_related('tenant'),
|
||||
instance.import_targets.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
export_targets_table = tables.RouteTargetTable(
|
||||
vrf.export_targets.prefetch_related('tenant'),
|
||||
instance.export_targets.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return render(request, 'ipam/vrf.html', {
|
||||
'vrf': vrf,
|
||||
return {
|
||||
'prefix_count': prefix_count,
|
||||
'import_targets_table': import_targets_table,
|
||||
'export_targets_table': export_targets_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VRFEditView(generic.ObjectEditView):
|
||||
@@ -93,23 +90,20 @@ class RouteTargetListView(generic.ObjectListView):
|
||||
class RouteTargetView(generic.ObjectView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
routetarget = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
importing_vrfs_table = tables.VRFTable(
|
||||
routetarget.importing_vrfs.prefetch_related('tenant'),
|
||||
instance.importing_vrfs.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
exporting_vrfs_table = tables.VRFTable(
|
||||
routetarget.exporting_vrfs.prefetch_related('tenant'),
|
||||
instance.exporting_vrfs.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return render(request, 'ipam/routetarget.html', {
|
||||
'routetarget': routetarget,
|
||||
return {
|
||||
'importing_vrfs_table': importing_vrfs_table,
|
||||
'exporting_vrfs_table': exporting_vrfs_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class RouteTargetEditView(generic.ObjectEditView):
|
||||
@@ -145,7 +139,9 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class RIRListView(generic.ObjectListView):
|
||||
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
filterset_form = forms.RIRFilterForm
|
||||
table = tables.RIRTable
|
||||
@@ -168,7 +164,9 @@ class RIRBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
table = tables.RIRTable
|
||||
|
||||
@@ -180,7 +178,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
class AggregateListView(generic.ObjectListView):
|
||||
queryset = Aggregate.objects.annotate(
|
||||
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
|
||||
).order_by(*Aggregate._meta.ordering)
|
||||
)
|
||||
filterset = filters.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateDetailTable
|
||||
@@ -206,13 +204,10 @@ class AggregateListView(generic.ObjectListView):
|
||||
class AggregateView(generic.ObjectView):
|
||||
queryset = Aggregate.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
aggregate = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Find all child prefixes contained by this aggregate
|
||||
child_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
prefix__net_contained_or_equal=str(aggregate.prefix)
|
||||
prefix__net_contained_or_equal=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
).order_by(
|
||||
@@ -221,7 +216,7 @@ class AggregateView(generic.ObjectView):
|
||||
|
||||
# Add available prefixes to the table if requested
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
@@ -240,12 +235,11 @@ class AggregateView(generic.ObjectView):
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/aggregate.html', {
|
||||
'aggregate': aggregate,
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class AggregateEditView(generic.ObjectEditView):
|
||||
@@ -283,8 +277,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
|
||||
|
||||
@@ -324,22 +318,19 @@ class PrefixListView(generic.ObjectListView):
|
||||
class PrefixView(generic.ObjectView):
|
||||
queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
prefix = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
try:
|
||||
aggregate = Aggregate.objects.restrict(request.user, 'view').get(
|
||||
prefix__net_contains_or_equals=str(prefix.prefix)
|
||||
prefix__net_contains_or_equals=str(instance.prefix)
|
||||
)
|
||||
except Aggregate.DoesNotExist:
|
||||
aggregate = None
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True)
|
||||
).filter(
|
||||
prefix__net_contains=str(prefix.prefix)
|
||||
prefix__net_contains=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
).annotate_tree()
|
||||
@@ -348,38 +339,35 @@ class PrefixView(generic.ObjectView):
|
||||
|
||||
# Duplicate prefixes table
|
||||
duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
vrf=prefix.vrf, prefix=str(prefix.prefix)
|
||||
vrf=instance.vrf, prefix=str(instance.prefix)
|
||||
).exclude(
|
||||
pk=prefix.pk
|
||||
pk=instance.pk
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
)
|
||||
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
|
||||
duplicate_prefix_table.exclude = ('vrf',)
|
||||
|
||||
return render(request, 'ipam/prefix.html', {
|
||||
'prefix': prefix,
|
||||
return {
|
||||
'aggregate': aggregate,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class PrefixPrefixesView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix_prefixes.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
prefix = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Child prefixes table
|
||||
child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
child_prefixes = instance.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
'site', 'vlan', 'role',
|
||||
).annotate_tree()
|
||||
|
||||
# Add available prefixes to the table if requested
|
||||
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
@@ -398,32 +386,31 @@ class PrefixPrefixesView(generic.ObjectView):
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix_prefixes.html', {
|
||||
'prefix': prefix,
|
||||
'first_available_prefix': prefix.get_first_available_prefix(),
|
||||
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
return {
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'prefixes',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class PrefixIPAddressesView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix_ipaddresses.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
prefix = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'primary_ip4_for', 'primary_ip6_for'
|
||||
)
|
||||
|
||||
# Add available IP addresses to the table if requested
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
@@ -442,15 +429,16 @@ class PrefixIPAddressesView(generic.ObjectView):
|
||||
'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix_ipaddresses.html', {
|
||||
'prefix': prefix,
|
||||
'first_available_ip': prefix.get_first_available_ip(),
|
||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
return {
|
||||
'first_available_ip': instance.get_first_available_ip(),
|
||||
'ip_table': ip_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'ip-addresses',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class PrefixEditView(generic.ObjectEditView):
|
||||
@@ -497,13 +485,11 @@ class IPAddressListView(generic.ObjectListView):
|
||||
class IPAddressView(generic.ObjectView):
|
||||
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
|
||||
vrf=instance.vrf,
|
||||
prefix__net_contains=str(instance.address.ip)
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
)
|
||||
@@ -512,23 +498,24 @@ class IPAddressView(generic.ObjectView):
|
||||
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
|
||||
vrf=ipaddress.vrf, address=str(ipaddress.address)
|
||||
vrf=instance.vrf,
|
||||
address=str(instance.address)
|
||||
).exclude(
|
||||
pk=ipaddress.pk
|
||||
pk=instance.pk
|
||||
).prefetch_related(
|
||||
'nat_inside'
|
||||
)
|
||||
# Exclude anycast IPs if this IP is anycast
|
||||
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
|
||||
if instance.role == IPAddressRoleChoices.ROLE_ANYCAST:
|
||||
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
|
||||
# Limit to a maximum of 10 duplicates displayed here
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
|
||||
address=str(ipaddress.address)
|
||||
address=str(instance.address)
|
||||
).filter(
|
||||
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
|
||||
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
|
||||
)
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
|
||||
@@ -538,13 +525,12 @@ class IPAddressView(generic.ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(related_ips_table)
|
||||
|
||||
return render(request, 'ipam/ipaddress.html', {
|
||||
'ipaddress': ipaddress,
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'more_duplicate_ips': duplicate_ips.count() > 10,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class IPAddressEditView(generic.ObjectEditView):
|
||||
@@ -569,6 +555,7 @@ class IPAddressEditView(generic.ObjectEditView):
|
||||
return obj
|
||||
|
||||
|
||||
# TODO: Standardize or remove this view
|
||||
class IPAddressAssignView(generic.ObjectView):
|
||||
"""
|
||||
Search for IPAddresses to be assigned to an Interface.
|
||||
@@ -645,9 +632,9 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
filterset_form = forms.VLANGroupFilterForm
|
||||
table = tables.VLANGroupTable
|
||||
@@ -670,22 +657,21 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
|
||||
|
||||
class VLANGroupVLANsView(generic.ObjectView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
template_name = 'ipam/vlangroup_vlans.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
vlan_group = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
|
||||
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
|
||||
)
|
||||
vlans = add_available_vlans(vlan_group, vlans)
|
||||
vlans = add_available_vlans(instance, vlans)
|
||||
|
||||
vlan_table = tables.VLANDetailTable(vlans)
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
@@ -706,13 +692,12 @@ class VLANGroupVLANsView(generic.ObjectView):
|
||||
'delete': request.user.has_perm('ipam.delete_vlan'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/vlangroup_vlans.html', {
|
||||
'vlan_group': vlan_group,
|
||||
'first_available_vlan': vlan_group.get_next_available_vid(),
|
||||
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
|
||||
return {
|
||||
'first_available_vlan': instance.get_next_available_vid(),
|
||||
'bulk_querystring': f'group_id={instance.pk}',
|
||||
'vlan_table': vlan_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
@@ -729,27 +714,24 @@ class VLANListView(generic.ObjectListView):
|
||||
class VLANView(generic.ObjectView):
|
||||
queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related(
|
||||
def get_extra_context(self, request, instance):
|
||||
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
|
||||
'vrf', 'site', 'role'
|
||||
)
|
||||
prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
|
||||
prefix_table.exclude = ('vlan',)
|
||||
|
||||
return render(request, 'ipam/vlan.html', {
|
||||
'vlan': vlan,
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VLANInterfacesView(generic.ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
template_name = 'ipam/vlan_interfaces.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
interfaces = vlan.get_interfaces().prefetch_related('device')
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.get_interfaces().prefetch_related('device')
|
||||
members_table = tables.VLANDevicesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
@@ -758,19 +740,18 @@ class VLANInterfacesView(generic.ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_interfaces.html', {
|
||||
'vlan': vlan,
|
||||
return {
|
||||
'members_table': members_table,
|
||||
'active_tab': 'interfaces',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VLANVMInterfacesView(generic.ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
template_name = 'ipam/vlan_vminterfaces.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine')
|
||||
members_table = tables.VLANVirtualMachinesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
@@ -779,11 +760,10 @@ class VLANVMInterfacesView(generic.ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_vminterfaces.html', {
|
||||
'vlan': vlan,
|
||||
return {
|
||||
'members_table': members_table,
|
||||
'active_tab': 'vminterfaces',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VLANEditView(generic.ObjectEditView):
|
||||
@@ -824,20 +804,12 @@ 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):
|
||||
queryset = Service.objects.prefetch_related('ipaddresses')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
service = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
return render(request, 'ipam/service.html', {
|
||||
'service': service,
|
||||
})
|
||||
|
||||
|
||||
class ServiceEditView(generic.ObjectEditView):
|
||||
queryset = Service.objects.prefetch_related('ipaddresses')
|
||||
|
||||
@@ -94,14 +94,14 @@ class ContentTypeField(RelatedField):
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
app_label, model = data.split('.')
|
||||
return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
|
||||
return self.queryset.get(app_label=app_label, model=model)
|
||||
except ObjectDoesNotExist:
|
||||
self.fail('does_not_exist', content_type=data)
|
||||
except (TypeError, ValueError):
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
self.fail('invalid')
|
||||
|
||||
def to_representation(self, obj):
|
||||
return "{}.{}".format(obj.app_label, obj.model)
|
||||
return f"{obj.app_label}.{obj.model}"
|
||||
|
||||
|
||||
class TimeZoneField(serializers.Field):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,11 +6,10 @@ from rest_framework.exceptions import ValidationError
|
||||
from utilities.utils import dict_to_filter_params
|
||||
|
||||
|
||||
# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant
|
||||
# way to enforce model validation on the serializer.
|
||||
class ValidatedModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
|
||||
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
def validate(self, data):
|
||||
|
||||
@@ -31,8 +30,7 @@ class ValidatedModelSerializer(serializers.ModelSerializer):
|
||||
instance = self.instance
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance.clean()
|
||||
instance.validate_unique()
|
||||
instance.full_clean()
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -183,7 +183,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 = {}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ ALLOWED_HOSTS = ['*']
|
||||
|
||||
DATABASE = {
|
||||
'NAME': 'netbox',
|
||||
'USER': '',
|
||||
'PASSWORD': '',
|
||||
'USER': 'netbox',
|
||||
'PASSWORD': 'netbox',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '',
|
||||
'CONN_MAX_AGE': 300,
|
||||
|
||||
@@ -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,8 +33,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=Count('circuits')
|
||||
).order_by(*Provider._meta.ordering),
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
@@ -61,17 +61,21 @@ SEARCH_TYPES = OrderedDict((
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackgroup', {
|
||||
'queryset': RackGroup.objects.prefetch_related('site').annotate(
|
||||
rack_count=Count('racks')
|
||||
).order_by(*RackGroup._meta.ordering),
|
||||
'queryset': RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
Rack,
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': RackGroupFilterSet,
|
||||
'table': RackGroupTable,
|
||||
'url': 'dcim:rackgroup_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering),
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
@@ -86,8 +90,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering),
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
@@ -107,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-beta1'
|
||||
VERSION = '2.10.3'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -211,9 +211,6 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
|
||||
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
|
||||
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
||||
# TODO: Remove in v2.10 (see #5171)
|
||||
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
|
||||
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
|
||||
|
||||
# Caching
|
||||
if 'caching' not in REDIS:
|
||||
@@ -470,6 +467,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',
|
||||
@@ -612,7 +610,7 @@ for plugin_name in PLUGINS:
|
||||
# Validate user-provided configuration settings and assign defaults
|
||||
if plugin_name not in PLUGINS_CONFIG:
|
||||
PLUGINS_CONFIG[plugin_name] = {}
|
||||
plugin_config.validate(PLUGINS_CONFIG[plugin_name])
|
||||
plugin_config.validate(PLUGINS_CONFIG[plugin_name], VERSION)
|
||||
|
||||
# Add middleware
|
||||
plugin_middleware = plugin_config.middleware
|
||||
|
||||
@@ -212,6 +212,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['view']
|
||||
)
|
||||
@@ -239,6 +240,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['view']
|
||||
)
|
||||
@@ -266,6 +268,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['add']
|
||||
)
|
||||
@@ -295,6 +298,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['change']
|
||||
)
|
||||
@@ -330,6 +334,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['delete']
|
||||
)
|
||||
|
||||
@@ -32,9 +32,11 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
|
||||
"""
|
||||
Retrieve a single object for display.
|
||||
|
||||
queryset: The base queryset for retrieving the object.
|
||||
queryset: The base queryset for retrieving the object
|
||||
template_name: Name of the template to use
|
||||
"""
|
||||
queryset = None
|
||||
template_name = None
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
@@ -43,19 +45,29 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
|
||||
"""
|
||||
Return self.template_name if set. Otherwise, resolve the template path by model app_label and name.
|
||||
"""
|
||||
if hasattr(self, 'template_name'):
|
||||
if self.template_name is not None:
|
||||
return self.template_name
|
||||
model_opts = self.queryset.model._meta
|
||||
return f'{model_opts.app_label}/{model_opts.model_name}.html'
|
||||
|
||||
def get(self, request, pk):
|
||||
def get_extra_context(self, request, instance):
|
||||
"""
|
||||
Generic GET handler for accessing an object by PK
|
||||
Return any additional context data for the template.
|
||||
|
||||
request: The current request
|
||||
instance: The object being viewed
|
||||
"""
|
||||
instance = get_object_or_404(self.queryset, pk=pk)
|
||||
return {}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Generic GET handler for accessing an object by PK or slug
|
||||
"""
|
||||
instance = get_object_or_404(self.queryset, **kwargs)
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'instance': instance,
|
||||
'object': instance,
|
||||
**self.get_extra_context(request, instance),
|
||||
})
|
||||
|
||||
|
||||
@@ -786,8 +798,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
# 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;
|
||||
@@ -377,6 +377,9 @@ table.report th a {
|
||||
ul.nav-tabs, ul.nav-pills {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
ul.nav-tabs li a {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
/* Fix progress bar margin inside table cells */
|
||||
td .progress {
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import base64
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseBadRequest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -13,6 +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 count_related
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@@ -35,8 +35,8 @@ class SecretsRootView(APIRootView):
|
||||
|
||||
class SecretRoleViewSet(ModelViewSet):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=Count('secrets')
|
||||
).order_by(*SecretRole._meta.ordering)
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
filterset_class = filters.SecretRoleFilterSet
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
|
||||
from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
|
||||
from virtualization.models import VirtualMachine
|
||||
from .models import Secret, SecretRole
|
||||
@@ -21,7 +21,7 @@ class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class SecretFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -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.")
|
||||
@@ -143,31 +144,56 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
|
||||
class SecretCSVForm(CustomFieldModelCSVForm):
|
||||
assigned_object_type = CSVModelChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=SECRET_ASSIGNMENT_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side A type'
|
||||
)
|
||||
role = CSVModelChoiceField(
|
||||
queryset=SecretRole.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Assigned role'
|
||||
)
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned device'
|
||||
)
|
||||
virtual_machine = CSVModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned VM'
|
||||
)
|
||||
plaintext = forms.CharField(
|
||||
help_text='Plaintext secret data'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = Secret.csv_headers
|
||||
fields = ['role', 'name', 'plaintext', 'device', 'virtual_machine']
|
||||
help_texts = {
|
||||
'name': 'Name or username',
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
device = self.cleaned_data.get('device')
|
||||
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||
|
||||
# Validate device OR VM is assigned
|
||||
if not device and not virtual_machine:
|
||||
raise forms.ValidationError("Secret must be assigned to a device or a virtual machine")
|
||||
if device and virtual_machine:
|
||||
raise forms.ValidationError("Secret cannot be assigned to both a device and a virtual machine")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set device/VM assignment
|
||||
self.instance.assigned_object = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
|
||||
|
||||
s = super().save(*args, **kwargs)
|
||||
|
||||
# Set plaintext on instance
|
||||
s.plaintext = str(self.cleaned_data['plaintext'])
|
||||
|
||||
return s
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user