Compare commits
359 Commits
v3.0-beta1
...
v3.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b55c85b2af | ||
|
|
0d1d14bcd6 | ||
|
|
8c1a01d5ab | ||
|
|
cf8fdacfa3 | ||
|
|
2c1745ce28 | ||
|
|
950ce94653 | ||
|
|
851f8a1585 | ||
|
|
d40d1638af | ||
|
|
26ceeb61ef | ||
|
|
a39a9c9b56 | ||
|
|
45988b9818 | ||
|
|
7234b3bbf8 | ||
|
|
513ecd7e26 | ||
|
|
e12314ba60 | ||
|
|
9226302742 | ||
|
|
0e8c6ee522 | ||
|
|
a9c1c8968e | ||
|
|
6a15c2ae86 | ||
|
|
752de0d9c0 | ||
|
|
49617a595d | ||
|
|
2a293d5d02 | ||
|
|
9d99ede024 | ||
|
|
4a13ee6f40 | ||
|
|
2ba840c72c | ||
|
|
46cd55151d | ||
|
|
4d9691c8e5 | ||
|
|
f1687ef53d | ||
|
|
2fb55374b9 | ||
|
|
312246fec2 | ||
|
|
27c0e6dd5e | ||
|
|
0d7986e082 | ||
|
|
94300b221e | ||
|
|
a1110b07de | ||
|
|
a3069239e9 | ||
|
|
69f083428d | ||
|
|
113358f2de | ||
|
|
caa2813d0d | ||
|
|
481046c8b8 | ||
|
|
83f70dc28c | ||
|
|
8ede7a9297 | ||
|
|
ddff193786 | ||
|
|
774dff07ee | ||
|
|
4b14b31853 | ||
|
|
b0addfbe13 | ||
|
|
593874b45f | ||
|
|
b207f28402 | ||
|
|
7bdde47473 | ||
|
|
a2eb0d80d2 | ||
|
|
6f94198934 | ||
|
|
707e51d855 | ||
|
|
528df76747 | ||
|
|
662c896480 | ||
|
|
29eb2383d6 | ||
|
|
9772c5705f | ||
|
|
d2fe59ae8f | ||
|
|
f63dcb1f08 | ||
|
|
6f66b27507 | ||
|
|
909d127c27 | ||
|
|
20ef18f98f | ||
|
|
a33e47780b | ||
|
|
691c66d2f5 | ||
|
|
14d87a3584 | ||
|
|
d743dc160a | ||
|
|
2b263b054c | ||
|
|
b95e8350d2 | ||
|
|
5235866d05 | ||
|
|
093a86bc38 | ||
|
|
5b87232f59 | ||
|
|
679bbd3e76 | ||
|
|
515b6bf71a | ||
|
|
9c389d9dcb | ||
|
|
f1e4273a23 | ||
|
|
4618cc2b22 | ||
|
|
1909f0c733 | ||
|
|
840ea36f70 | ||
|
|
a8cdb3895b | ||
|
|
349733c6dd | ||
|
|
1c09ffdd1f | ||
|
|
c4c6fa6042 | ||
|
|
86da6c6c14 | ||
|
|
7b7b01a26b | ||
|
|
415313ac2f | ||
|
|
7db2b9d091 | ||
|
|
8036d1e5a5 | ||
|
|
65c9339687 | ||
|
|
3090981335 | ||
|
|
4f36885c5e | ||
|
|
db2993035d | ||
|
|
bf05bc2986 | ||
|
|
88b230f0e4 | ||
|
|
deb53d771d | ||
|
|
fd16c47d2e | ||
|
|
b78451742f | ||
|
|
6f23ab5603 | ||
|
|
5e67627e6b | ||
|
|
19e77ed456 | ||
|
|
ed0f792f04 | ||
|
|
deda1691e9 | ||
|
|
94d2ad120c | ||
|
|
ab1a5f32ef | ||
|
|
2a1de5e28c | ||
|
|
f78fdd6900 | ||
|
|
6b43eafcb4 | ||
|
|
844cd154b9 | ||
|
|
e05fa5c302 | ||
|
|
f5f74944dd | ||
|
|
556efcc1d7 | ||
|
|
25d1fe2c8d | ||
|
|
1a478150d6 | ||
|
|
e5643fb1e2 | ||
|
|
13e633778a | ||
|
|
bb57600f0f | ||
|
|
9813f3b696 | ||
|
|
3203db07b7 | ||
|
|
94b8d36065 | ||
|
|
0d61dcb1bc | ||
|
|
58203dbcfa | ||
|
|
66619cdc2f | ||
|
|
99cba25108 | ||
|
|
2fb1d388e3 | ||
|
|
6a4ed099fc | ||
|
|
d184ed4712 | ||
|
|
125a562189 | ||
|
|
a02ba5f7bb | ||
|
|
2e90f22529 | ||
|
|
bd681f5908 | ||
|
|
85b61c0b7e | ||
|
|
d11ea67bdd | ||
|
|
52603c087b | ||
|
|
545474a1a3 | ||
|
|
b63c838c74 | ||
|
|
1c6fdea27f | ||
|
|
9d0e6f0c30 | ||
|
|
1d0c72f5fa | ||
|
|
c221b9b4d4 | ||
|
|
a0ba8380c9 | ||
|
|
82a209bc5b | ||
|
|
2a338110f2 | ||
|
|
e890944160 | ||
|
|
542e01775e | ||
|
|
9cc4992fad | ||
|
|
6518d87200 | ||
|
|
499005f84d | ||
|
|
8497965cf7 | ||
|
|
0b0ab9277c | ||
|
|
75c62ff729 | ||
|
|
aef8c5fbb5 | ||
|
|
cfa4f5677b | ||
|
|
8131feae8a | ||
|
|
a3d5e04946 | ||
|
|
1fc3c6d9d2 | ||
|
|
53a5bc2221 | ||
|
|
12f3c2596f | ||
|
|
87dad41c37 | ||
|
|
4dbb18d408 | ||
|
|
a7cb75d73d | ||
|
|
517c0e2fe6 | ||
|
|
84db2e90ab | ||
|
|
9d469874c0 | ||
|
|
d850aa0773 | ||
|
|
9baebfa241 | ||
|
|
9e1d2da449 | ||
|
|
a71604e79f | ||
|
|
9a8d33e6bf | ||
|
|
09d745d987 | ||
|
|
8199bb6b62 | ||
|
|
643939ea1e | ||
|
|
9b3498d87a | ||
|
|
e4a162b054 | ||
|
|
bd47d0850e | ||
|
|
be3b4f0d3e | ||
|
|
664b02d735 | ||
|
|
6d1b981ecb | ||
|
|
ac6b1bf422 | ||
|
|
10847e2956 | ||
|
|
9b0258fef4 | ||
|
|
5b89cdc868 | ||
|
|
5a8cedd63f | ||
|
|
3feba2997f | ||
|
|
fce419526d | ||
|
|
e8fb86a283 | ||
|
|
90a820e0cf | ||
|
|
9f59f99663 | ||
|
|
a6150f2578 | ||
|
|
b784705cd3 | ||
|
|
0609bcaaf0 | ||
|
|
7727ec91f4 | ||
|
|
5365c866ff | ||
|
|
e1fbe89b41 | ||
|
|
a72e23eddf | ||
|
|
dcd49fd97b | ||
|
|
1b074d2d53 | ||
|
|
aed07a8ec5 | ||
|
|
1b12185a39 | ||
|
|
2e895c734e | ||
|
|
11a9dc57fc | ||
|
|
badd92a50e | ||
|
|
b2faf8044d | ||
|
|
3105e9545a | ||
|
|
42c71984f9 | ||
|
|
736da4bcad | ||
|
|
db359719a9 | ||
|
|
7bceeb714b | ||
|
|
35b8fc6e83 | ||
|
|
6d27e11043 | ||
|
|
b8e387ce98 | ||
|
|
c7ebad0fbb | ||
|
|
1bb596fc7e | ||
|
|
7bcebd5b0f | ||
|
|
a8b6902829 | ||
|
|
71e6dc8275 | ||
|
|
564640213e | ||
|
|
b04f262642 | ||
|
|
b802127801 | ||
|
|
6845fb0f00 | ||
|
|
a312311be9 | ||
|
|
fe54acef51 | ||
|
|
ef057b3e45 | ||
|
|
84ab233571 | ||
|
|
cf381d732d | ||
|
|
8653b0f3d0 | ||
|
|
65659fb676 | ||
|
|
939bcfec4b | ||
|
|
6ce8dd5ac3 | ||
|
|
63f4d81bc0 | ||
|
|
d0fbbbfb37 | ||
|
|
f23dc2d405 | ||
|
|
34aa231436 | ||
|
|
51d1b6e0d6 | ||
|
|
7c8612aadd | ||
|
|
d347b97f20 | ||
|
|
42b961229f | ||
|
|
79f726e6cd | ||
|
|
31cd6898d4 | ||
|
|
7608ee8450 | ||
|
|
da67a35328 | ||
|
|
46d0af6cef | ||
|
|
0ea9c65007 | ||
|
|
57dc4c207f | ||
|
|
582b69de74 | ||
|
|
0cf9be2a8d | ||
|
|
0bf39590e3 | ||
|
|
2debeb7475 | ||
|
|
ee8fd701ae | ||
|
|
9379324b07 | ||
|
|
55cdbd57cc | ||
|
|
11836cdfb1 | ||
|
|
c411d2a9f1 | ||
|
|
1b612816cc | ||
|
|
051abc00c4 | ||
|
|
f7ee5e8d78 | ||
|
|
cc26bc4858 | ||
|
|
88d2441ab3 | ||
|
|
6842879985 | ||
|
|
1518a460d5 | ||
|
|
ea86321da8 | ||
|
|
c416fce400 | ||
|
|
ae28df8abd | ||
|
|
e8ba4b0564 | ||
|
|
53e21ceed4 | ||
|
|
735286d3b0 | ||
|
|
8ad958708f | ||
|
|
58862e115c | ||
|
|
0df67dbc12 | ||
|
|
8bdfa34c7d | ||
|
|
06c730f4dc | ||
|
|
afc8d5bbbf | ||
|
|
1de46f592c | ||
|
|
863048cda2 | ||
|
|
0b09365d0d | ||
|
|
8e3ab8d5c5 | ||
|
|
9cf560ceec | ||
|
|
c3a75d98d4 | ||
|
|
261372289a | ||
|
|
b86edd4a20 | ||
|
|
374cf146e2 | ||
|
|
08ed545065 | ||
|
|
80836c725c | ||
|
|
9fa2acfe85 | ||
|
|
3ba122afd4 | ||
|
|
76df55dfc0 | ||
|
|
49a949aa97 | ||
|
|
5413263eff | ||
|
|
772c76e0a4 | ||
|
|
5463fa7390 | ||
|
|
d18c83beb0 | ||
|
|
7aa89c2e73 | ||
|
|
007d660ce1 | ||
|
|
3752cb3e56 | ||
|
|
cdf8d91e1b | ||
|
|
d082442851 | ||
|
|
689f67b1a8 | ||
|
|
744f47cb98 | ||
|
|
81e1b7490e | ||
|
|
22d160b1da | ||
|
|
c323105696 | ||
|
|
f6746c7530 | ||
|
|
52c4d54481 | ||
|
|
4c3f584fa6 | ||
|
|
2e7d912bdd | ||
|
|
288bf477ce | ||
|
|
27f3816fc6 | ||
|
|
33d40d4253 | ||
|
|
c7e0abc3fb | ||
|
|
18a4232783 | ||
|
|
15ed575207 | ||
|
|
eae4502708 | ||
|
|
78ebf04be0 | ||
|
|
49a596073e | ||
|
|
95783cc128 | ||
|
|
8d9d3a9e7d | ||
|
|
ea0de4b01d | ||
|
|
72aaf76cf4 | ||
|
|
78e282d406 | ||
|
|
0c214932ba | ||
|
|
a1eb4dc807 | ||
|
|
e92f13977c | ||
|
|
5db283700f | ||
|
|
6e79e5608e | ||
|
|
8355270a1a | ||
|
|
5a8835f41a | ||
|
|
2d32aeb972 | ||
|
|
1c38d63c50 | ||
|
|
4f6944424b | ||
|
|
fc01bedd45 | ||
|
|
7ab916b527 | ||
|
|
51c1f4b214 | ||
|
|
0b80d85c6c | ||
|
|
4489e130f2 | ||
|
|
e1cc00ad17 | ||
|
|
49191261a1 | ||
|
|
0479d5a02a | ||
|
|
189e733f81 | ||
|
|
bf2d535356 | ||
|
|
05cfdd0b69 | ||
|
|
a60e8d3e12 | ||
|
|
7b3d285884 | ||
|
|
5ba053a1c0 | ||
|
|
7d5f647cd3 | ||
|
|
0572d03003 | ||
|
|
f25649955e | ||
|
|
04d6a4a371 | ||
|
|
a8140d1f70 | ||
|
|
d1af15037c | ||
|
|
cca76550d6 | ||
|
|
2ff3d0d5a2 | ||
|
|
ffae2c5f18 | ||
|
|
e300fad340 | ||
|
|
1e7b76005c | ||
|
|
0a661596b3 | ||
|
|
934543b595 | ||
|
|
55b7cf21cc | ||
|
|
3549fc07f6 | ||
|
|
ecd84d7c43 | ||
|
|
c2b2b059e6 | ||
|
|
6ff5a1db42 | ||
|
|
2bc68707b5 | ||
|
|
0c9376039c | ||
|
|
e1fe3ca14a |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -17,7 +17,7 @@ body:
|
||||
What version of NetBox are you currently running? (If you don't have access to the most
|
||||
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
|
||||
before opening a bug report to see if your issue has already been addressed.)
|
||||
placeholder: v2.11.9
|
||||
placeholder: v3.0.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v2.11.9
|
||||
placeholder: v3.0.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -30,8 +30,10 @@ body:
|
||||
attributes:
|
||||
label: Proposed functionality
|
||||
description: >
|
||||
Describe in detail the new feature or behavior you'd like to propose. Include any specific
|
||||
changes to work flows, data models, or the user interface.
|
||||
Describe in detail the new feature or behavior you are proposing. Include any specific changes
|
||||
to work flows, data models, and/or the user interface. The more detail you provide here, the
|
||||
greater chance your proposal has of being discussed. Feature requests which don't include an
|
||||
actionable implementation plan will be rejected.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
@@ -6,6 +6,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
node-version: [14.x]
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
@@ -33,12 +34,18 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-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
|
||||
yarn --cwd netbox/project-static
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
@@ -47,7 +54,13 @@ jobs:
|
||||
run: python netbox/manage.py collectstatic --no-input
|
||||
|
||||
- name: Check PEP8 compliance
|
||||
run: pycodestyle --ignore=W504,E501 netbox/
|
||||
run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/
|
||||
|
||||
- name: Check UI ESLint, TypeScript, and Prettier Compliance
|
||||
run: yarn --cwd netbox/project-static validate
|
||||
|
||||
- name: Validate Static Asset Integrity
|
||||
run: scripts/verify-bundles.sh
|
||||
|
||||
- name: Run tests
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
3
.gitignore
vendored
@@ -1,10 +1,9 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/netbox/project-static/.cache
|
||||
/netbox/project-static/node_modules
|
||||
/netbox/project-static/docs/*
|
||||
!/netbox/project-static/docs/.info
|
||||
/netbox/netbox/configuration.py
|
||||
|
||||
10
README.md
@@ -54,13 +54,15 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
")
|
||||
|
||||

|
||||
")
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
### Related projects
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl ipv6only=off;
|
||||
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
server_name netbox.example.com;
|
||||
@@ -23,7 +23,7 @@ server {
|
||||
|
||||
server {
|
||||
# Redirect HTTP traffic to HTTPS
|
||||
listen 80;
|
||||
listen [::]:80 ipv6only=off;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# NAPALM
|
||||
|
||||
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
|
||||
NetBox supports integration with the [NAPALM automation](https://github.com/napalm-automation/napalm) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
|
||||
|
||||
The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
|
||||
|
||||
|
||||
@@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
|
||||
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
|
||||
|
||||
!!! warning
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized environment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
|
||||
|
||||
!!! warning
|
||||
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
|
||||
|
||||
## Configuration
|
||||
|
||||
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
|
||||
|
||||
@@ -273,6 +273,16 @@ LOGGING = {
|
||||
|
||||
---
|
||||
|
||||
## LOGIN_PERSISTENCE
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
|
||||
|
||||
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## LOGIN_REQUIRED
|
||||
|
||||
Default: False
|
||||
@@ -333,7 +343,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
|
||||
|
||||
## NAPALM_PASSWORD
|
||||
|
||||
NetBox will use these credentials when authenticating to remote devices via the [NAPALM library](https://napalm-automation.net/), if installed. Both parameters are optional.
|
||||
NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
|
||||
|
||||
!!! note
|
||||
If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
|
||||
|
||||
@@ -17,6 +17,9 @@ When viewing a device named Router4, this link would render as:
|
||||
|
||||
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
|
||||
!!! warning
|
||||
Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users.
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
@@ -4,10 +4,13 @@ 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. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
|
||||
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
|
||||
!!! note
|
||||
The name `table` is reserved for internal use.
|
||||
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
!!! warning
|
||||
Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
85
docs/development/adding-models.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Adding Models
|
||||
|
||||
## 1. Define the model class
|
||||
|
||||
Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module.
|
||||
|
||||
Each model should define, at a minimum:
|
||||
|
||||
* A `__str__()` method returning a user-friendly string representation of the instance
|
||||
* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`)
|
||||
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
|
||||
|
||||
## 2. Define field choices
|
||||
|
||||
If the model has one or more fields with static choices, define those choices in `choices.py` by subclassing `utilities.choices.ChoiceSet`.
|
||||
|
||||
## 3. Generate database migrations
|
||||
|
||||
Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations.
|
||||
|
||||
!!! info
|
||||
Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations.
|
||||
|
||||
## 4. Add all standard views
|
||||
|
||||
Most models will need view classes created in `views.py` to serve the following operations:
|
||||
|
||||
* List view
|
||||
* Detail view
|
||||
* Edit view
|
||||
* Delete view
|
||||
* Bulk import
|
||||
* Bulk edit
|
||||
* Bulk delete
|
||||
|
||||
## 5. Add URL paths
|
||||
|
||||
Add the relevant URL path for each view created in the previous step to `urls.py`.
|
||||
|
||||
## 6. Create the FilterSet
|
||||
|
||||
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
|
||||
|
||||
Every model FilterSet should define a `q` filter to support general search queries.
|
||||
|
||||
## 7. Create the table
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 8. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 9. Add the model to the navigation menu
|
||||
|
||||
For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`.
|
||||
|
||||
## 10. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
* Detailed (full) model serializer in `api/serializers.py`
|
||||
* Nested serializer in `api/nested_serializers.py`
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 11. GraphQL API components (v3.0+)
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 12. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
* UI views
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 13. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
Also add your model to the index in `docs/development/models.md`.
|
||||
@@ -34,11 +34,11 @@ class Foo(models.Model):
|
||||
|
||||
## 3. 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 extraneous database queries.
|
||||
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retrieving 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.
|
||||
|
||||
## 4. Update API serializer
|
||||
|
||||
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
|
||||
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
|
||||
|
||||
## 5. Add field to forms
|
||||
|
||||
|
||||
99
docs/development/web-ui.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Web UI Development
|
||||
|
||||
## Front End Technologies
|
||||
|
||||
The NetBox UI is built on languages and frameworks:
|
||||
|
||||
### Styling & HTML Elements
|
||||
|
||||
#### [Bootstrap](https://getbootstrap.com/) 5
|
||||
|
||||
The majority of the NetBox UI is made up of stock Bootstrap components, with some styling modifications and custom components added on an as-needed basis. Bootstrap uses [Sass](https://sass-lang.com/), and NetBox extends Bootstrap's core Sass files for theming and customization.
|
||||
|
||||
### Client-side Scripting
|
||||
|
||||
#### [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
All client-side scripting is transpiled from TypeScript to JavaScript and served by Django. In development, TypeScript is an _extremely_ effective tool for accurately describing and checking the code, which leads to significantly fewer bugs, a better development experience, and more predictable/readable code.
|
||||
|
||||
As part of the [bundling](#bundling) process, Bootstrap's JavaScript plugins are imported and bundled alongside NetBox's front-end code.
|
||||
|
||||
!!! danger "NetBox is jQuery-free"
|
||||
Following the Bootstrap team's deprecation of jQuery in Bootstrap 5, NetBox also no longer uses jQuery in front-end code.
|
||||
|
||||
## Guidance
|
||||
|
||||
NetBox generally follows the following guidelines for front-end code:
|
||||
|
||||
- Bootstrap utility classes may be used to solve one-off issues or to implement singular components, as long as the class list does not exceed 4-5 classes. If an element needs more than 5 utility classes, a custom SCSS class should be added that contains the required style properties.
|
||||
- Custom classes must be commented, explaining the general purpose of the class and where it is used.
|
||||
- Reuse SCSS variables whenever possible. CSS values should (almost) never be hard-coded.
|
||||
- All TypeScript functions must have, at a minimum, a basic [JSDoc](https://jsdoc.app/) description of what the function is for and where it is used. If possible, document all function arguments via [`@param` JSDoc block tags](https://jsdoc.app/tags-param.html).
|
||||
- Expanding on NetBox's [dependency policy](style-guide.md#introducing-new-dependencies), new front-end dependencies should be avoided unless absolutely necessary. Every new front-end dependency adds to the CSS/JavaScript file size that must be loaded by the client and this should be minimized as much as possible. If adding a new dependency is unavoidable, use a tool like [Bundlephobia](https://bundlephobia.com/) to ensure the smallest possible library is used.
|
||||
- All UI elements must be usable on all common screen sizes, including mobile devices. Be sure to test newly implemented solutions (JavaScript included) on as many screen sizes and device types as possible.
|
||||
- NetBox aligns with Bootstrap's [supported Browsers and Devices](https://getbootstrap.com/docs/5.1/getting-started/browsers-devices/) list.
|
||||
|
||||
## UI Development
|
||||
|
||||
To contribute to the NetBox UI, you'll need to review the main [Getting Started guide](getting-started.md) in order to set up your base environment.
|
||||
|
||||
### Tools
|
||||
|
||||
Once you have a working NetBox development environment, you'll need to install a few more tools to work with the NetBox UI:
|
||||
|
||||
- [NodeJS](https://nodejs.org/en/download/) (the LTS release should suffice)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (version 1)
|
||||
|
||||
After Node and Yarn are installed on your system, you'll need to install all the NetBox UI dependencies:
|
||||
|
||||
```console
|
||||
$ cd netbox/project-static
|
||||
$ yarn
|
||||
```
|
||||
|
||||
!!! warning "Check Your Working Directory"
|
||||
You need to be in the `netbox/project-static` directory to run the below `yarn` commands.
|
||||
|
||||
### Bundling
|
||||
|
||||
In order for the TypeScript and Sass (SCSS) source files to be usable by a browser, they must first be transpiled (TypeScript → JavaScript, Sass → CSS), bundled, and minified. After making changes to TypeScript or Sass source files, run `yarn bundle`.
|
||||
|
||||
`yarn bundle` is a wrapper around the following subcommands, any of which can be run individually:
|
||||
|
||||
| Command | Action |
|
||||
| :-------------------- | :---------------------------------------------- |
|
||||
| `yarn bundle` | Bundle TypeScript and Sass (SCSS) source files. |
|
||||
| `yarn bundle:styles` | Bundle Sass (SCSS) source files only. |
|
||||
| `yarn bundle:scripts` | Bundle TypeScript source files only. |
|
||||
|
||||
All output files will be written to `netbox/project-static/dist`, where Django will pick them up when `manage.py collectstatic` is run.
|
||||
|
||||
!!! info "Remember to re-run `manage.py collectstatic`"
|
||||
If you're running the development web server — `manage.py runserver` — you'll need to run `manage.py collectstatic` to see your changes.
|
||||
|
||||
### Linting, Formatting & Type Checking
|
||||
|
||||
Before committing any changes to TypeScript files, and periodically throughout the development process, you should run `yarn validate` to catch formatting, code quality, or type errors.
|
||||
|
||||
!!! tip "IDE Integrations"
|
||||
If you're using an IDE, it is strongly recommended to install [ESLint](https://eslint.org/docs/user-guide/integrations), [TypeScript](https://github.com/Microsoft/TypeScript/wiki/TypeScript-Editor-Support), and [Prettier](https://prettier.io/docs/en/editors.html) integrations, if available. Most of them will automatically check and/or correct issues in the code as you develop, which can significantly increase your productivity as a contributor.
|
||||
|
||||
`yarn validate` is a wrapper around the following subcommands, any of which can be run individually:
|
||||
|
||||
| Command | Action |
|
||||
| :--------------------------------- | :--------------------------------------------------------------- |
|
||||
| `yarn validate` | Run all validation. |
|
||||
| `yarn validate:lint` | Validate TypeScript code via [ESLint](https://eslint.org/) only. |
|
||||
| `yarn validate:types` | Validate TypeScript code compilation only. |
|
||||
| `yarn validate:formatting` | Validate code formatting of JavaScript & Sass/SCSS files. |
|
||||
| `yarn validate:formatting:styles` | Validate code formatting Sass/SCSS only. |
|
||||
| `yarn validate:formatting:scripts` | Validate code formatting TypeScript only. |
|
||||
|
||||
You can also run the following commands to automatically fix formatting issues:
|
||||
|
||||
| Command | Action |
|
||||
| :-------------------- | :---------------------------------------------- |
|
||||
| `yarn format` | Format TypeScript and Sass (SCSS) source files. |
|
||||
| `yarn format:styles` | Format Sass (SCSS) source files only. |
|
||||
| `yarn format:scripts` | Format TypeScript source files only. |
|
||||
|
||||
@@ -11,9 +11,19 @@ table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
background-color: #f0f0f0;
|
||||
padding: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
td {
|
||||
padding: 6px;
|
||||
}
|
||||
/* Remove table header coloring. */
|
||||
.md-typeset table:not([class]) th {
|
||||
color: unset !important;
|
||||
background-color: unset !important;
|
||||
}
|
||||
thead tr {
|
||||
/* Colorize table headers. */
|
||||
background-color: var(--md-code-bg-color);
|
||||
color: var(--md-code-fg-color);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ NetBox provides both a singular and plural query field for each object type:
|
||||
* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
|
||||
* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
|
||||
|
||||
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices.
|
||||
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
|
||||
|
||||
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# What is NetBox?
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
|
||||
```no-highlight
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql libpq-dev
|
||||
sudo apt install -y postgresql
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y postgresql-server libpq-devel
|
||||
sudo yum install -y postgresql-server
|
||||
sudo postgresql-setup --initdb
|
||||
```
|
||||
|
||||
@@ -40,28 +40,28 @@ sudo systemctl enable postgresql
|
||||
|
||||
## Database Creation
|
||||
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. Start by invoking the PostgreSQL shell as the system Postgres user.
|
||||
|
||||
```no-highlight
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
Within the shell, enter the following commands to create the database and user (role), substituting your own value for the password:
|
||||
|
||||
```postgresql
|
||||
CREATE DATABASE netbox;
|
||||
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
```
|
||||
|
||||
!!! danger
|
||||
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
|
||||
|
||||
```no-highlight
|
||||
$ sudo -u postgres psql
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
CREATE DATABASE
|
||||
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
CREATE ROLE
|
||||
postgres=# GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
GRANT
|
||||
postgres=# \q
|
||||
```
|
||||
Once complete, enter `\q` to exit the PostgreSQL shell.
|
||||
|
||||
## Verify Service Status
|
||||
|
||||
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
|
||||
You can verify that authentication works by executing the `psql` command and passing the configured username and password. (Replace `localhost` with your database server if using a remote database.)
|
||||
|
||||
```no-highlight
|
||||
$ psql --username netbox --password --host localhost netbox
|
||||
|
||||
@@ -28,6 +28,7 @@ You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/red
|
||||
Use the `redis-cli` utility to ensure the Redis service is functional:
|
||||
|
||||
```no-highlight
|
||||
$ redis-cli ping
|
||||
PONG
|
||||
redis-cli ping
|
||||
```
|
||||
|
||||
If successful, you should receive a `PONG` response from the server.
|
||||
|
||||
@@ -18,7 +18,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
@@ -36,23 +36,21 @@ This documentation provides two options for installing NetBox: from a downloadab
|
||||
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
|
||||
|
||||
```no-highlight
|
||||
$ sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
$ sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
$ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
$ ls -l /opt | grep netbox
|
||||
lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
|
||||
drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
|
||||
sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
!!! note
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v2.9.0 would be installed into `/opt/netbox-2.9.0`, and a symlink from `/opt/netbox/` would point to this location. This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
|
||||
```no-highlight
|
||||
sudo mkdir -p /opt/netbox/ && cd /opt/netbox/
|
||||
sudo mkdir -p /opt/netbox/
|
||||
cd /opt/netbox/
|
||||
```
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
@@ -72,19 +70,22 @@ If `git` is not already installed, install it:
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
|
||||
|
||||
```no-highlight
|
||||
sudo git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
|
||||
```
|
||||
|
||||
The screen below should be the result:
|
||||
!!! note
|
||||
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
|
||||
|
||||
The `git clone` command should generate output similar to the following:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
|
||||
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
|
||||
Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
remote: Enumerating objects: 996, done.
|
||||
remote: Counting objects: 100% (996/996), done.
|
||||
remote: Compressing objects: 100% (935/935), done.
|
||||
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
|
||||
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
||||
Resolving deltas: 100% (148/148), done.
|
||||
```
|
||||
|
||||
!!! note
|
||||
@@ -200,7 +201,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
|
||||
|
||||
### NAPALM
|
||||
|
||||
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
|
||||
Integration with the [NAPALM automation](../additional-features/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
|
||||
|
||||
```no-highlight
|
||||
sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
|
||||
@@ -250,13 +251,8 @@ Once the virtual environment has been activated, you should notice the string `(
|
||||
Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox
|
||||
(venv) $ python3 manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
cd /opt/netbox/netbox
|
||||
python3 manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Schedule the Housekeeping Task
|
||||
@@ -276,18 +272,31 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
|
||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
```
|
||||
|
||||
If successful, you should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
Watching for file changes with StatReloader
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
November 17, 2020 - 16:08:13
|
||||
Django version 3.1.3, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
August 30, 2021 - 18:02:23
|
||||
Django version 3.2.6, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
|
||||
|
||||
!!! note
|
||||
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
|
||||
|
||||
```no-highlight
|
||||
firewall-cmd --zone=public --add-port=8000/tcp
|
||||
```
|
||||
|
||||
!!! danger
|
||||
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ While the provided configuration should suffice for most initial installations,
|
||||
|
||||
## systemd Setup
|
||||
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
|
||||
|
||||
```no-highlight
|
||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||
@@ -31,18 +31,23 @@ sudo systemctl enable netbox netbox-rq
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
|
||||
```no-highlight
|
||||
# systemctl status netbox.service
|
||||
systemctl status netbox.service
|
||||
```
|
||||
|
||||
You should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Tue 2020-11-17 16:18:23 UTC; 3min 35s ago
|
||||
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 22836 (gunicorn)
|
||||
Tasks: 6 (limit: 2345)
|
||||
Memory: 339.3M
|
||||
Main PID: 1140492 (gunicorn)
|
||||
Tasks: 19 (limit: 4683)
|
||||
Memory: 666.2M
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─22836 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─22854 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─22855 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the
|
||||
### User Authentication
|
||||
|
||||
!!! info
|
||||
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
||||
When using Windows Server 2012+, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
||||
|
||||
```python
|
||||
from django_auth_ldap.config import LDAPSearch
|
||||
|
||||
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 467 KiB |
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 819 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 916 KiB |
|
Before Width: | Height: | Size: 911 KiB |
|
Before Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 569 KiB |
BIN
docs/media/screenshots/cable-trace.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/media/screenshots/home-dark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/media/screenshots/home-light.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/media/screenshots/prefixes-list.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/media/screenshots/rack.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -4,6 +4,6 @@ A platform defines the type of software running on a device or virtual machine.
|
||||
|
||||
Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||
|
||||
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
The platform model is also used to indicate which NAPALM driver (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
|
||||
@@ -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](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
|
||||
The [NAPALM automation](https://github.com/napalm-automation/napalm) 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,14 +1,74 @@
|
||||
# NetBox v2.11
|
||||
|
||||
## v2.11.10 (FUTURE)
|
||||
## v2.11.12 (2021-08-23)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6748](https://github.com/netbox-community/netbox/issues/6748) - Add site group filter to devices list
|
||||
* [#6790](https://github.com/netbox-community/netbox/issues/6790) - Recognize a /32 IPv4 address as a child of a /32 IPv4 prefix
|
||||
* [#6872](https://github.com/netbox-community/netbox/issues/6872) - Add table configuration button to child prefixes view
|
||||
* [#6929](https://github.com/netbox-community/netbox/issues/6929) - Introduce `LOGIN_PERSISTENCE` configuration parameter to persist user sessions
|
||||
* [#7011](https://github.com/netbox-community/netbox/issues/7011) - Add search field to VM interfaces filter form
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5968](https://github.com/netbox-community/netbox/issues/5968) - Model forms should save empty custom field values as null
|
||||
* [#6326](https://github.com/netbox-community/netbox/issues/6326) - Enable filtering assigned VLANs by group in interface edit form
|
||||
* [#6686](https://github.com/netbox-community/netbox/issues/6686) - Force assignment of null custom field values to objects
|
||||
* [#6776](https://github.com/netbox-community/netbox/issues/6776) - Fix erroneous webhook dispatch on failure to save objects
|
||||
* [#6974](https://github.com/netbox-community/netbox/issues/6974) - Show contextual label for IP address role
|
||||
* [#7012](https://github.com/netbox-community/netbox/issues/7012) - Fix hidden "add components" dropdown on devices list
|
||||
|
||||
---
|
||||
|
||||
## v2.11.11 (2021-08-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6883](https://github.com/netbox-community/netbox/issues/6883) - Add C21 & C22 power types
|
||||
* [#6921](https://github.com/netbox-community/netbox/issues/6921) - Employ a sandbox when rendering Jinja2 code for increased security
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6740](https://github.com/netbox-community/netbox/issues/6740) - Add import button to VM interfaces list
|
||||
* [#6892](https://github.com/netbox-community/netbox/issues/6892) - Fix validation of unit ranges when creating a rack reservation
|
||||
* [#6896](https://github.com/netbox-community/netbox/issues/6896) - Fix validation of IP address assigned as device/VM primary via NAT relation
|
||||
* [#6902](https://github.com/netbox-community/netbox/issues/6902) - Populate device field when cloning device components
|
||||
* [#6908](https://github.com/netbox-community/netbox/issues/6908) - Allow assignment of scope to VLAN groups upon import
|
||||
* [#6909](https://github.com/netbox-community/netbox/issues/6909) - Remove extraneous `site` column from VLAN group import form
|
||||
* [#6910](https://github.com/netbox-community/netbox/issues/6910) - Fix exception on invalid CSV import column name
|
||||
* [#6918](https://github.com/netbox-community/netbox/issues/6918) - Fix return URL persistence when adding multiple objects sequentially
|
||||
* [#6935](https://github.com/netbox-community/netbox/issues/6935) - Remove extraneous columns from inventory item and device bay tables
|
||||
* [#6936](https://github.com/netbox-community/netbox/issues/6936) - Add missing `parent` column to inventory item import form
|
||||
|
||||
---
|
||||
|
||||
## v2.11.10 (2021-07-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6560](https://github.com/netbox-community/netbox/issues/6560) - Enable CSV import via uploaded file
|
||||
* [#6644](https://github.com/netbox-community/netbox/issues/6644) - Add 6P/4P pass-through port types
|
||||
* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view
|
||||
* [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups
|
||||
* [#5627](https://github.com/netbox-community/netbox/issues/5627) - Fix filtering of interface connections list
|
||||
* [#6759](https://github.com/netbox-community/netbox/issues/6759) - Fix assignment of parent interfaces for bulk import
|
||||
* [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer
|
||||
* [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations
|
||||
* [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields
|
||||
* [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location
|
||||
* [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs
|
||||
* [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view
|
||||
* [#6812](https://github.com/netbox-community/netbox/issues/6812) - Limit reported prefix utilization to 100%
|
||||
* [#6822](https://github.com/netbox-community/netbox/issues/6822) - Use consistent maximum value for interface MTU
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,61 @@
|
||||
# NetBox v3.0
|
||||
|
||||
## v3.0-beta1 (2021-07-23)
|
||||
## v3.0.2 (2021-09-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7131](https://github.com/netbox-community/netbox/issues/7131) - Fix issue where Site fields were hidden when editing a VLAN group
|
||||
* [#7148](https://github.com/netbox-community/netbox/issues/7148) - Fix issue where static query parameters with multiple values were not queried properly
|
||||
* [#7153](https://github.com/netbox-community/netbox/issues/7153) - Allow clearing of assigned device type images
|
||||
* [#7162](https://github.com/netbox-community/netbox/issues/7162) - Ensure consistent treatment of `BASE_PATH` for UI-driven API requests
|
||||
* [#7164](https://github.com/netbox-community/netbox/issues/7164) - Fix styling of "decommissioned" label for circuits
|
||||
* [#7169](https://github.com/netbox-community/netbox/issues/7169) - Fix CSV import file upload
|
||||
* [#7176](https://github.com/netbox-community/netbox/issues/7176) - Fix issue where query parameters were duplicated across different forms of the same type
|
||||
* [#7179](https://github.com/netbox-community/netbox/issues/7179) - Prevent obscuring "connect" pop-up for interfaces under device view
|
||||
* [#7188](https://github.com/netbox-community/netbox/issues/7188) - Fix issue where select fields with `null_option` did not render or send the null option
|
||||
* [#7189](https://github.com/netbox-community/netbox/issues/7189) - Set connection factory for django-redis when Sentinel is in use
|
||||
* [#7191](https://github.com/netbox-community/netbox/issues/7191) - Fix issue where API-backed multi-select elements cleared selected options when adding new options
|
||||
* [#7193](https://github.com/netbox-community/netbox/issues/7193) - Fix prefix (flat) template issue when viewing child prefixes with prefixes available
|
||||
* [#7205](https://github.com/netbox-community/netbox/issues/7205) - Fix issue where selected fields with `null_option` set were not added to applied filters
|
||||
* [#7209](https://github.com/netbox-community/netbox/issues/7209) - Allow unlimited API results when `MAX_PAGE_SIZE` is disabled
|
||||
|
||||
---
|
||||
|
||||
## v3.0.1 (2021-09-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device
|
||||
* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
|
||||
* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
|
||||
* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
|
||||
* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
|
||||
* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews
|
||||
* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data
|
||||
* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
|
||||
* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
|
||||
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
|
||||
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission
|
||||
* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type
|
||||
* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables
|
||||
* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH`
|
||||
* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table
|
||||
* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
|
||||
* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
|
||||
* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
|
||||
* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field
|
||||
* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab
|
||||
* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests
|
||||
* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects
|
||||
* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field
|
||||
* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select
|
||||
|
||||
---
|
||||
|
||||
## v3.0.0 (2021-08-30)
|
||||
|
||||
!!! warning "Existing Deployments Must Upgrade from v2.11"
|
||||
Upgrading an existing NetBox deployment to version 3.0 **must** be done from version 2.11.0 or later. If attempting to upgrade a deployment of NetBox v2.10 or earlier, first upgrade to a NetBox v2.11 release, and then upgrade from v2.11 to v3.0. This will avoid any problems with the database migration optimizations implemented in version 3.0.
|
||||
Upgrading an existing NetBox deployment to version 3.0 **must** be done from version 2.11.0 or later. If attempting to upgrade a deployment of NetBox v2.10 or earlier, first upgrade to a NetBox v2.11 release, and then upgrade from v2.11 to v3.0. This will avoid any problems with the database migration optimizations implemented in version 3.0. (This is not necessary for _new_ installations.)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -129,11 +181,11 @@ The new REST API endpoint `/api/users/tokens/` has been added, which includes a
|
||||
$ curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
https://netbox/api/users/tokens/provision/
|
||||
{
|
||||
https://netbox/api/users/tokens/provision/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password: "I<3C3H8",
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
If the supplied credentials are valid, NetBox will create and return a new token for the user.
|
||||
@@ -178,6 +230,16 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
|
||||
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
|
||||
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
|
||||
|
||||
### Bug Fixes (from v3.2-beta2)
|
||||
|
||||
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
|
||||
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations
|
||||
* [#6982](https://github.com/netbox-community/netbox/issues/6982) - Fix styling of empty dropdown list under dark mode
|
||||
* [#6996](https://github.com/netbox-community/netbox/issues/6996) - Global search bar should be full width on mobile
|
||||
* [#7001](https://github.com/netbox-community/netbox/issues/7001) - Fix page focus on load
|
||||
* [#7034](https://github.com/netbox-community/netbox/issues/7034) - Fix toggling of VLAN group scope selector fields
|
||||
* [#7045](https://github.com/netbox-community/netbox/issues/7045) - Fix navigation menu rendering under Chrome
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#5223](https://github.com/netbox-community/netbox/issues/5223) - Remove the console/power/interface connections REST API endpoints
|
||||
|
||||
@@ -39,11 +39,11 @@ To provision a token via the REST API, make a `POST` request to the `/api/users/
|
||||
$ curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
https://netbox/api/users/tokens/provision/
|
||||
{
|
||||
https://netbox/api/users/tokens/provision/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password: "I<3C3H8",
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled.
|
||||
|
||||
@@ -69,6 +69,12 @@ Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
|
||||
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/vlans/?vid__gt=900
|
||||
```
|
||||
|
||||
### String Fields
|
||||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
@@ -86,7 +92,17 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty (boolean) |
|
||||
|
||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/devices/?name__ic=switch
|
||||
```
|
||||
|
||||
### Foreign Keys & Other Fields
|
||||
|
||||
Certain other fields, namely foreign key relationships support just the negation
|
||||
expression: `n`.
|
||||
expression: `n`. Here is an example of a lookup expression on a foreign key, it would return all the VLANs that don't have a VLAN Group ID of 3203:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/vlans/?group_id__n=3203
|
||||
```
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Screenshots
|
||||
|
||||
## Light Mode
|
||||
|
||||
### Home Page
|
||||
|
||||

|
||||
|
||||
### Rack Elevation
|
||||
|
||||

|
||||
|
||||
### Prefixes
|
||||
|
||||

|
||||
|
||||
### Cable Trace
|
||||

|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Home Page
|
||||
|
||||

|
||||
|
||||
### Rack Elevation
|
||||
|
||||

|
||||
|
||||
### Prefixes
|
||||
|
||||

|
||||
|
||||
### Cable Trace
|
||||

|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
site_name: NetBox Documentation
|
||||
site_dir: netbox/project-static/docs
|
||||
site_url: https://netbox.readthedocs.io/
|
||||
repo_name: netbox-community/netbox
|
||||
repo_url: https://github.com/netbox-community/netbox
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
theme:
|
||||
name: material
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
palette:
|
||||
- scheme: default
|
||||
toggle:
|
||||
@@ -26,6 +29,7 @@ extra_css:
|
||||
- extra.css
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- markdown_include.include:
|
||||
headingOffset: 1
|
||||
- pymdownx.emoji:
|
||||
@@ -94,10 +98,12 @@ nav:
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
- Style Guide: 'development/style-guide.md'
|
||||
- Models: 'development/models.md'
|
||||
- Adding Models: 'development/adding-models.md'
|
||||
- Extending Models: 'development/extending-models.md'
|
||||
- Signals: 'development/signals.md'
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Web UI: 'development/web-ui.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Version 3.0: 'release-notes/version-3.0.md'
|
||||
@@ -113,4 +119,3 @@ nav:
|
||||
- Version 2.2: 'release-notes/version-2.2.md'
|
||||
- Version 2.1: 'release-notes/version-2.1.md'
|
||||
- Version 2.0: 'release-notes/version-2.0.md'
|
||||
- Screenshots: 'screenshots/index.md'
|
||||
|
||||
@@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_PROVISIONING: 'primary',
|
||||
STATUS_OFFLINE: 'danger',
|
||||
STATUS_DECOMMISSIONED: 'default',
|
||||
STATUS_DECOMMISSIONED: 'secondary',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,21 +107,36 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
|
||||
class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = Provider
|
||||
field_groups = [
|
||||
['region_id', 'site_id'],
|
||||
['asn', 'tag'],
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['asn'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
required=False,
|
||||
@@ -194,11 +209,20 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
|
||||
|
||||
class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = ProviderNetwork
|
||||
field_order = ['provider_id']
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('provider_id',),
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label=_('Provider')
|
||||
label=_('Provider'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -354,26 +378,29 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Circuit
|
||||
field_order = [
|
||||
'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id',
|
||||
'commit_rate',
|
||||
]
|
||||
field_groups = [
|
||||
['type_id', 'status', 'commit_rate'],
|
||||
['q', 'tag'],
|
||||
['provider_id', 'provider_network_id'],
|
||||
['region_id', 'site_id'],
|
||||
['type_id', 'status', 'commit_rate'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['tag']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
label=_('Type')
|
||||
label=_('Type'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label=_('Provider')
|
||||
label=_('Provider'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
provider_network_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
@@ -381,7 +408,8 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
query_params={
|
||||
'provider_id': '$provider_id'
|
||||
},
|
||||
label=_('Provider network')
|
||||
label=_('Provider network'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=CircuitStatusChoices,
|
||||
@@ -391,15 +419,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from circuits import filtersets, models
|
||||
from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
|
||||
from netbox.graphql.types import ObjectType, OrganizationalObjectType, PrimaryObjectType
|
||||
|
||||
__all__ = (
|
||||
'CircuitTerminationType',
|
||||
@@ -10,7 +10,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CircuitTerminationType(BaseObjectType):
|
||||
class CircuitTerminationType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CircuitTermination
|
||||
@@ -18,7 +18,7 @@ class CircuitTerminationType(BaseObjectType):
|
||||
filterset_class = filtersets.CircuitTerminationFilterSet
|
||||
|
||||
|
||||
class CircuitType(TaggedObjectType):
|
||||
class CircuitType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Circuit
|
||||
@@ -26,7 +26,7 @@ class CircuitType(TaggedObjectType):
|
||||
filterset_class = filtersets.CircuitFilterSet
|
||||
|
||||
|
||||
class CircuitTypeType(ObjectType):
|
||||
class CircuitTypeType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CircuitType
|
||||
@@ -34,7 +34,7 @@ class CircuitTypeType(ObjectType):
|
||||
filterset_class = filtersets.CircuitTypeFilterSet
|
||||
|
||||
|
||||
class ProviderType(TaggedObjectType):
|
||||
class ProviderType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Provider
|
||||
@@ -42,7 +42,7 @@ class ProviderType(TaggedObjectType):
|
||||
filterset_class = filtersets.ProviderFilterSet
|
||||
|
||||
|
||||
class ProviderNetworkType(TaggedObjectType):
|
||||
class ProviderNetworkType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ProviderNetwork
|
||||
|
||||
@@ -287,6 +287,10 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
termination_z.save()
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
circuit.refresh_from_db()
|
||||
circuit.termination_a = termination_z
|
||||
circuit.termination_z = termination_a
|
||||
circuit.save()
|
||||
elif termination_a:
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
@@ -300,9 +304,6 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
circuit.termination_z = None
|
||||
circuit.save()
|
||||
|
||||
print(f'term A: {circuit.termination_a}')
|
||||
print(f'term Z: {circuit.termination_z}')
|
||||
|
||||
messages.success(request, f"Swapped terminations for circuit {circuit}.")
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
|
||||
@@ -22,7 +22,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 count_related
|
||||
from utilities.utils import count_related, decode_dict
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||
continue
|
||||
try:
|
||||
response[method] = getattr(d, method)()
|
||||
response[method] = decode_dict(getattr(d, method)())
|
||||
except NotImplementedError:
|
||||
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
||||
except Exception as e:
|
||||
|
||||
@@ -252,6 +252,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C14 = 'iec-60320-c14'
|
||||
TYPE_IEC_C16 = 'iec-60320-c16'
|
||||
TYPE_IEC_C20 = 'iec-60320-c20'
|
||||
TYPE_IEC_C22 = 'iec-60320-c22'
|
||||
# IEC 60309
|
||||
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
|
||||
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
|
||||
@@ -341,6 +342,8 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_DC = 'dc-terminal'
|
||||
# Proprietary
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -349,6 +352,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C14, 'C14'),
|
||||
(TYPE_IEC_C16, 'C16'),
|
||||
(TYPE_IEC_C20, 'C20'),
|
||||
(TYPE_IEC_C22, 'C22'),
|
||||
)),
|
||||
('IEC 60309', (
|
||||
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
|
||||
@@ -447,6 +451,9 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
('Proprietary', (
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -462,6 +469,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C13 = 'iec-60320-c13'
|
||||
TYPE_IEC_C15 = 'iec-60320-c15'
|
||||
TYPE_IEC_C19 = 'iec-60320-c19'
|
||||
TYPE_IEC_C21 = 'iec-60320-c21'
|
||||
# IEC 60309
|
||||
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
|
||||
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
|
||||
@@ -545,6 +553,8 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
# Proprietary
|
||||
TYPE_HDOT_CX = 'hdot-cx'
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -553,6 +563,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C13, 'C13'),
|
||||
(TYPE_IEC_C15, 'C15'),
|
||||
(TYPE_IEC_C19, 'C19'),
|
||||
(TYPE_IEC_C21, 'C21'),
|
||||
)),
|
||||
('IEC 60309', (
|
||||
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
|
||||
@@ -645,6 +656,9 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -917,6 +931,11 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_6P6C = '6p6c'
|
||||
TYPE_6P4C = '6p4c'
|
||||
TYPE_6P2C = '6p2c'
|
||||
TYPE_4P4C = '4p4c'
|
||||
TYPE_4P2C = '4p2c'
|
||||
TYPE_GG45 = 'gg45'
|
||||
TYPE_TERA4P = 'tera-4p'
|
||||
TYPE_TERA2P = 'tera-2p'
|
||||
@@ -948,6 +967,11 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_6P6C, '6P6C'),
|
||||
(TYPE_6P4C, '6P4C'),
|
||||
(TYPE_6P2C, '6P2C'),
|
||||
(TYPE_4P4C, '4P4C'),
|
||||
(TYPE_4P2C, '4P2C'),
|
||||
(TYPE_GG45, 'GG45'),
|
||||
(TYPE_TERA4P, 'TERA 4P'),
|
||||
(TYPE_TERA2P, 'TERA 2P'),
|
||||
|
||||
@@ -29,7 +29,7 @@ REARPORT_POSITIONS_MAX = 1024
|
||||
#
|
||||
|
||||
INTERFACE_MTU_MIN = 1
|
||||
INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
|
||||
INTERFACE_MTU_MAX = 65536
|
||||
|
||||
VIRTUAL_IFACE_TYPES = [
|
||||
InterfaceTypeChoices.TYPE_VIRTUAL,
|
||||
|
||||
@@ -831,6 +831,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location',
|
||||
queryset=Location.objects.all(),
|
||||
label='Location (ID)',
|
||||
)
|
||||
location = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location__slug',
|
||||
queryset=Location.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Location (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
@@ -1053,39 +1064,6 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region',
|
||||
lookup_expr='in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
label='Parent inventory item (ID)',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from dcim import filtersets, models
|
||||
from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
|
||||
from extras.graphql.mixins import (
|
||||
ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
|
||||
)
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
'ComponentObjectType',
|
||||
'ConsolePortType',
|
||||
'ConsolePortTemplateType',
|
||||
'ConsoleServerPortType',
|
||||
@@ -38,7 +43,40 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CableType(TaggedObjectType):
|
||||
#
|
||||
# Base types
|
||||
#
|
||||
|
||||
|
||||
class ComponentObjectType(
|
||||
ChangelogMixin,
|
||||
CustomFieldsMixin,
|
||||
TagsMixin,
|
||||
BaseObjectType
|
||||
):
|
||||
"""
|
||||
Base type for device/VM components
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class ComponentTemplateObjectType(
|
||||
ChangelogMixin,
|
||||
BaseObjectType
|
||||
):
|
||||
"""
|
||||
Base type for device/VM components
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
#
|
||||
# Model types
|
||||
#
|
||||
|
||||
class CableType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Cable
|
||||
@@ -52,7 +90,7 @@ class CableType(TaggedObjectType):
|
||||
return self.length_unit or None
|
||||
|
||||
|
||||
class ConsolePortType(TaggedObjectType):
|
||||
class ConsolePortType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@@ -63,7 +101,7 @@ class ConsolePortType(TaggedObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsolePortTemplateType(BaseObjectType):
|
||||
class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePortTemplate
|
||||
@@ -74,7 +112,7 @@ class ConsolePortTemplateType(BaseObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(TaggedObjectType):
|
||||
class ConsoleServerPortType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@@ -85,7 +123,7 @@ class ConsoleServerPortType(TaggedObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateType(BaseObjectType):
|
||||
class ConsoleServerPortTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPortTemplate
|
||||
@@ -96,7 +134,7 @@ class ConsoleServerPortTemplateType(BaseObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class DeviceType(TaggedObjectType):
|
||||
class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Device
|
||||
@@ -107,7 +145,7 @@ class DeviceType(TaggedObjectType):
|
||||
return self.face or None
|
||||
|
||||
|
||||
class DeviceBayType(TaggedObjectType):
|
||||
class DeviceBayType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceBay
|
||||
@@ -115,7 +153,7 @@ class DeviceBayType(TaggedObjectType):
|
||||
filterset_class = filtersets.DeviceBayFilterSet
|
||||
|
||||
|
||||
class DeviceBayTemplateType(BaseObjectType):
|
||||
class DeviceBayTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceBayTemplate
|
||||
@@ -123,7 +161,7 @@ class DeviceBayTemplateType(BaseObjectType):
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class DeviceRoleType(ObjectType):
|
||||
class DeviceRoleType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceRole
|
||||
@@ -131,7 +169,7 @@ class DeviceRoleType(ObjectType):
|
||||
filterset_class = filtersets.DeviceRoleFilterSet
|
||||
|
||||
|
||||
class DeviceTypeType(TaggedObjectType):
|
||||
class DeviceTypeType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceType
|
||||
@@ -142,7 +180,7 @@ class DeviceTypeType(TaggedObjectType):
|
||||
return self.subdevice_role or None
|
||||
|
||||
|
||||
class FrontPortType(TaggedObjectType):
|
||||
class FrontPortType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.FrontPort
|
||||
@@ -150,7 +188,7 @@ class FrontPortType(TaggedObjectType):
|
||||
filterset_class = filtersets.FrontPortFilterSet
|
||||
|
||||
|
||||
class FrontPortTemplateType(BaseObjectType):
|
||||
class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.FrontPortTemplate
|
||||
@@ -158,7 +196,7 @@ class FrontPortTemplateType(BaseObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(TaggedObjectType):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
@@ -169,7 +207,7 @@ class InterfaceType(TaggedObjectType):
|
||||
return self.mode or None
|
||||
|
||||
|
||||
class InterfaceTemplateType(BaseObjectType):
|
||||
class InterfaceTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.InterfaceTemplate
|
||||
@@ -177,7 +215,7 @@ class InterfaceTemplateType(BaseObjectType):
|
||||
filterset_class = filtersets.InterfaceTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemType(TaggedObjectType):
|
||||
class InventoryItemType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItem
|
||||
@@ -185,7 +223,7 @@ class InventoryItemType(TaggedObjectType):
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
|
||||
|
||||
class LocationType(ObjectType):
|
||||
class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Location
|
||||
@@ -193,7 +231,7 @@ class LocationType(ObjectType):
|
||||
filterset_class = filtersets.LocationFilterSet
|
||||
|
||||
|
||||
class ManufacturerType(ObjectType):
|
||||
class ManufacturerType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Manufacturer
|
||||
@@ -201,7 +239,7 @@ class ManufacturerType(ObjectType):
|
||||
filterset_class = filtersets.ManufacturerFilterSet
|
||||
|
||||
|
||||
class PlatformType(ObjectType):
|
||||
class PlatformType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Platform
|
||||
@@ -209,7 +247,7 @@ class PlatformType(ObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(TaggedObjectType):
|
||||
class PowerFeedType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@@ -217,7 +255,7 @@ class PowerFeedType(TaggedObjectType):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(TaggedObjectType):
|
||||
class PowerOutletType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@@ -231,7 +269,7 @@ class PowerOutletType(TaggedObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class PowerOutletTemplateType(BaseObjectType):
|
||||
class PowerOutletTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutletTemplate
|
||||
@@ -245,7 +283,7 @@ class PowerOutletTemplateType(BaseObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class PowerPanelType(TaggedObjectType):
|
||||
class PowerPanelType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPanel
|
||||
@@ -253,7 +291,7 @@ class PowerPanelType(TaggedObjectType):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(TaggedObjectType):
|
||||
class PowerPortType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
@@ -264,7 +302,7 @@ class PowerPortType(TaggedObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class PowerPortTemplateType(BaseObjectType):
|
||||
class PowerPortTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPortTemplate
|
||||
@@ -275,7 +313,7 @@ class PowerPortTemplateType(BaseObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class RackType(TaggedObjectType):
|
||||
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Rack
|
||||
@@ -289,7 +327,7 @@ class RackType(TaggedObjectType):
|
||||
return self.outer_unit or None
|
||||
|
||||
|
||||
class RackReservationType(TaggedObjectType):
|
||||
class RackReservationType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RackReservation
|
||||
@@ -297,7 +335,7 @@ class RackReservationType(TaggedObjectType):
|
||||
filterset_class = filtersets.RackReservationFilterSet
|
||||
|
||||
|
||||
class RackRoleType(ObjectType):
|
||||
class RackRoleType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RackRole
|
||||
@@ -305,7 +343,7 @@ class RackRoleType(ObjectType):
|
||||
filterset_class = filtersets.RackRoleFilterSet
|
||||
|
||||
|
||||
class RearPortType(TaggedObjectType):
|
||||
class RearPortType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RearPort
|
||||
@@ -313,7 +351,7 @@ class RearPortType(TaggedObjectType):
|
||||
filterset_class = filtersets.RearPortFilterSet
|
||||
|
||||
|
||||
class RearPortTemplateType(BaseObjectType):
|
||||
class RearPortTemplateType(ComponentTemplateObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RearPortTemplate
|
||||
@@ -321,7 +359,7 @@ class RearPortTemplateType(BaseObjectType):
|
||||
filterset_class = filtersets.RearPortTemplateFilterSet
|
||||
|
||||
|
||||
class RegionType(ObjectType):
|
||||
class RegionType(VLANGroupsMixin, OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Region
|
||||
@@ -329,7 +367,7 @@ class RegionType(ObjectType):
|
||||
filterset_class = filtersets.RegionFilterSet
|
||||
|
||||
|
||||
class SiteType(TaggedObjectType):
|
||||
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Site
|
||||
@@ -337,7 +375,7 @@ class SiteType(TaggedObjectType):
|
||||
filterset_class = filtersets.SiteFilterSet
|
||||
|
||||
|
||||
class SiteGroupType(ObjectType):
|
||||
class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.SiteGroup
|
||||
@@ -345,7 +383,7 @@ class SiteGroupType(ObjectType):
|
||||
filterset_class = filtersets.SiteGroupFilterSet
|
||||
|
||||
|
||||
class VirtualChassisType(TaggedObjectType):
|
||||
class VirtualChassisType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualChassis
|
||||
|
||||
@@ -237,6 +237,8 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
|
||||
help_text='Port speed in bits per second'
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
@@ -267,6 +269,8 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
|
||||
help_text='Port speed in bits per second'
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
@@ -303,6 +307,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
|
||||
help_text="Allocated power draw (watts)"
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'maximum_draw', 'allocated_draw']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
@@ -399,6 +405,8 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'power_port', 'feed_leg']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
@@ -435,7 +443,10 @@ class BaseInterface(models.Model):
|
||||
mtu = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65536)],
|
||||
validators=[
|
||||
MinValueValidator(INTERFACE_MTU_MIN),
|
||||
MaxValueValidator(INTERFACE_MTU_MAX)
|
||||
],
|
||||
verbose_name='MTU'
|
||||
)
|
||||
mode = models.CharField(
|
||||
@@ -522,6 +533,8 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
|
||||
related_query_name='interface'
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
unique_together = ('device', 'name')
|
||||
@@ -638,6 +651,8 @@ class FrontPort(ComponentModel, CableTermination):
|
||||
]
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = (
|
||||
@@ -684,6 +699,7 @@ class RearPort(ComponentModel, CableTermination):
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
clone_fields = ['device', 'type', 'positions']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -721,6 +737,8 @@ class DeviceBay(ComponentModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
clone_fields = ['device']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
@@ -803,6 +821,8 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
clone_fields = ['device', 'parent', 'manufacturer', 'part_id']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device__id', 'parent__id', '_name')
|
||||
unique_together = ('device', 'parent', 'name')
|
||||
|
||||
@@ -175,6 +175,12 @@ class Rack(PrimaryModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='rack'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
@@ -53,6 +53,12 @@ class Region(NestedGroupModel):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='region'
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:region', args=[self.pk])
|
||||
@@ -95,6 +101,12 @@ class SiteGroup(NestedGroupModel):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site_group'
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:sitegroup', args=[self.pk])
|
||||
@@ -210,6 +222,12 @@ class Site(PrimaryModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@@ -267,6 +285,12 @@ class Location(NestedGroupModel):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='location'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
@@ -446,10 +446,18 @@ class CableTraceSVG:
|
||||
if connector is not None:
|
||||
|
||||
# Cable
|
||||
cable_labels = [
|
||||
f'Cable {connector}',
|
||||
connector.get_status_display()
|
||||
]
|
||||
if connector.type:
|
||||
cable_labels.append(connector.get_type_display())
|
||||
if connector.length and connector.length_unit:
|
||||
cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
|
||||
cable = self._draw_cable(
|
||||
color=connector.color or '000000',
|
||||
url=connector.get_absolute_url(),
|
||||
labels=[f'Cable {connector}', connector.get_status_display()]
|
||||
labels=cable_labels
|
||||
)
|
||||
connectors.append(cable)
|
||||
|
||||
|
||||
@@ -242,10 +242,6 @@ class DeviceComponentTable(BaseTable):
|
||||
linkify=True,
|
||||
order_by=('_name',)
|
||||
)
|
||||
cable = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
mark_connected = BooleanColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
order_by = ('device', 'name')
|
||||
@@ -292,10 +288,10 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
|
||||
class DeviceConsolePortTable(ConsolePortTable):
|
||||
@@ -336,10 +332,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
|
||||
class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
@@ -381,10 +377,10 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
|
||||
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
|
||||
'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
class DevicePowerPortTable(PowerPortTable):
|
||||
@@ -432,10 +428,10 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
|
||||
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
|
||||
class DevicePowerOutletTable(PowerOutletTable):
|
||||
@@ -494,11 +490,11 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
class DeviceInterfaceTable(InterfaceTable):
|
||||
@@ -563,11 +559,11 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
)
|
||||
|
||||
|
||||
@@ -614,10 +610,10 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'color', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
||||
|
||||
|
||||
class DeviceRearPortTable(RearPortTable):
|
||||
@@ -666,8 +662,8 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description')
|
||||
fields = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
class DeviceDeviceBayTable(DeviceBayTable):
|
||||
@@ -712,10 +708,10 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
|
||||
|
||||
class DeviceInventoryItemTable(InventoryItemTable):
|
||||
|
||||
@@ -41,9 +41,19 @@ DEVICEBAY_STATUS = """
|
||||
"""
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
|
||||
{% endfor %}
|
||||
<div class="table-badge-group">
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
<a
|
||||
class="table-badge{% if ip.status != 'active' %} badge bg-{{ ip.get_status_class }}{% elif ip.role %} badge bg-{{ ip.get_role_class }}{% endif %}"
|
||||
href="{{ ip.get_absolute_url }}"
|
||||
{% if ip.status != 'active'%}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}"
|
||||
{% elif ip.role %}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_role_display }}"
|
||||
{% endif %}
|
||||
>
|
||||
{{ ip }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
INTERFACE_TAGGED_VLANS = """
|
||||
|
||||
@@ -1512,10 +1512,18 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -1584,6 +1592,13 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1624,10 +1639,18 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -1689,6 +1712,13 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -1736,10 +1766,18 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -1809,6 +1847,13 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -1856,10 +1901,18 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -1925,6 +1978,13 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -1972,10 +2032,18 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -2082,6 +2150,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -2143,10 +2218,18 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -2217,6 +2300,13 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -2264,10 +2354,18 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
@@ -2332,6 +2430,13 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -2379,10 +2484,18 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2426,6 +2539,13 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
@@ -2474,10 +2594,18 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2541,13 +2669,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device(self):
|
||||
# TODO: Allow multiple values
|
||||
device = Device.objects.first()
|
||||
params = {'device_id': device.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device': device.name}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_parent_id(self):
|
||||
parent_items = InventoryItem.objects.filter(parent__isnull=True)[:2]
|
||||
|
||||
@@ -1469,7 +1469,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'enabled': False,
|
||||
'lag': interfaces[3].pk,
|
||||
'mac_address': EUI('01:02:03:04:05:06'),
|
||||
'mtu': 2000,
|
||||
'mtu': 65000,
|
||||
'mgmt_only': True,
|
||||
'description': 'A front port',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
@@ -1741,10 +1741,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name",
|
||||
"Device 1,Inventory Item 4",
|
||||
"Device 1,Inventory Item 5",
|
||||
"Device 1,Inventory Item 6",
|
||||
"device,name,parent",
|
||||
"Device 1,Inventory Item 4,Inventory Item 1",
|
||||
"Device 1,Inventory Item 5,Inventory Item 2",
|
||||
"Device 1,Inventory Item 6,Inventory Item 3",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -696,6 +696,9 @@ class ManufacturerView(generic.ObjectView):
|
||||
).annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
|
||||
manufacturer=instance
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes)
|
||||
devicetypes_table.columns.hide('manufacturer')
|
||||
@@ -703,6 +706,7 @@ class ManufacturerView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'devicetypes_table': devicetypes_table,
|
||||
'inventory_item_count': inventory_items.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -872,7 +876,6 @@ class ConsolePortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePortTemplate.objects.all()
|
||||
form = forms.ConsolePortTemplateCreateForm
|
||||
model_form = forms.ConsolePortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsolePortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -907,7 +910,6 @@ class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPortTemplate.objects.all()
|
||||
form = forms.ConsoleServerPortTemplateCreateForm
|
||||
model_form = forms.ConsoleServerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -942,7 +944,6 @@ class PowerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPortTemplate.objects.all()
|
||||
form = forms.PowerPortTemplateCreateForm
|
||||
model_form = forms.PowerPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -977,7 +978,6 @@ class PowerOutletTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutletTemplate.objects.all()
|
||||
form = forms.PowerOutletTemplateCreateForm
|
||||
model_form = forms.PowerOutletTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerOutletTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1012,7 +1012,6 @@ class InterfaceTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InterfaceTemplate.objects.all()
|
||||
form = forms.InterfaceTemplateCreateForm
|
||||
model_form = forms.InterfaceTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class InterfaceTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1047,7 +1046,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPortTemplate.objects.all()
|
||||
form = forms.FrontPortTemplateCreateForm
|
||||
model_form = forms.FrontPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class FrontPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1082,7 +1080,6 @@ class RearPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPortTemplate.objects.all()
|
||||
form = forms.RearPortTemplateCreateForm
|
||||
model_form = forms.RearPortTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class RearPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1117,7 +1114,6 @@ class DeviceBayTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBayTemplate.objects.all()
|
||||
form = forms.DeviceBayTemplateCreateForm
|
||||
model_form = forms.DeviceBayTemplateForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class DeviceBayTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1634,7 +1630,6 @@ class ConsolePortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
form = forms.ConsolePortCreateForm
|
||||
model_form = forms.ConsolePortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsolePortEditView(generic.ObjectEditView):
|
||||
@@ -1694,7 +1689,6 @@ class ConsoleServerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
form = forms.ConsoleServerPortCreateForm
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class ConsoleServerPortEditView(generic.ObjectEditView):
|
||||
@@ -1754,7 +1748,6 @@ class PowerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPort.objects.all()
|
||||
form = forms.PowerPortCreateForm
|
||||
model_form = forms.PowerPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerPortEditView(generic.ObjectEditView):
|
||||
@@ -1814,7 +1807,6 @@ class PowerOutletCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
form = forms.PowerOutletCreateForm
|
||||
model_form = forms.PowerOutletForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class PowerOutletEditView(generic.ObjectEditView):
|
||||
@@ -1909,28 +1901,30 @@ class InterfaceCreateView(generic.ComponentCreateView):
|
||||
queryset = Interface.objects.all()
|
||||
form = forms.InterfaceCreateForm
|
||||
model_form = forms.InterfaceForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
template_name = 'dcim/interface_create.html'
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Override inherited post() method to handle request to assign newly created
|
||||
interface objects (first object) to an IP Address object.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.views.InterfaceCreateView')
|
||||
form = self.form(request.POST, initial=request.GET)
|
||||
new_objs = self.validate_form(request, form)
|
||||
|
||||
if form.is_valid() and not form.errors:
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and request.user.has_perm('ipam.add_ipaddress'):
|
||||
elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
|
||||
request.user.has_perm('ipam.add_ipaddress'):
|
||||
first_obj = new_objs[0].pk
|
||||
return redirect(f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}')
|
||||
return redirect(
|
||||
f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
|
||||
)
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'component_type': self.queryset.model._meta.verbose_name,
|
||||
'obj_type': self.queryset.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
@@ -1993,7 +1987,6 @@ class FrontPortCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPort.objects.all()
|
||||
form = forms.FrontPortCreateForm
|
||||
model_form = forms.FrontPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class FrontPortEditView(generic.ObjectEditView):
|
||||
@@ -2053,7 +2046,6 @@ class RearPortCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPort.objects.all()
|
||||
form = forms.RearPortCreateForm
|
||||
model_form = forms.RearPortForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class RearPortEditView(generic.ObjectEditView):
|
||||
@@ -2113,7 +2105,6 @@ class DeviceBayCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
form = forms.DeviceBayCreateForm
|
||||
model_form = forms.DeviceBayForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class DeviceBayEditView(generic.ObjectEditView):
|
||||
@@ -2239,7 +2230,6 @@ class InventoryItemCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
form = forms.InventoryItemCreateForm
|
||||
model_form = forms.InventoryItemForm
|
||||
template_name = 'dcim/device_component_add.html'
|
||||
|
||||
|
||||
class InventoryItemDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2537,6 +2527,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.ConsoleConnectionFilterForm
|
||||
table = tables.ConsoleConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
|
||||
def extra_context(self):
|
||||
return {
|
||||
@@ -2550,6 +2541,7 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.PowerConnectionFilterForm
|
||||
table = tables.PowerConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
|
||||
def extra_context(self):
|
||||
return {
|
||||
@@ -2558,15 +2550,12 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
queryset = Interface.objects.filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_path__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
).order_by('device')
|
||||
queryset = Interface.objects.filter(_path__isnull=False).order_by('device')
|
||||
filterset = filtersets.InterfaceConnectionFilterSet
|
||||
filterset_form = forms.InterfaceConnectionFilterForm
|
||||
table = tables.InterfaceConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
|
||||
def extra_context(self):
|
||||
return {
|
||||
|
||||
@@ -46,28 +46,40 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
|
||||
class CustomLinkButtonClassChoices(ChoiceSet):
|
||||
|
||||
CLASS_DEFAULT = 'outline-dark'
|
||||
CLASS_PRIMARY = 'primary'
|
||||
CLASS_SUCCESS = 'success'
|
||||
CLASS_INFO = 'info'
|
||||
CLASS_WARNING = 'warning'
|
||||
CLASS_DANGER = 'danger'
|
||||
CLASS_LINK = 'link'
|
||||
CLASS_LINK = 'ghost-dark'
|
||||
CLASS_BLUE = 'blue'
|
||||
CLASS_INDIGO = 'indigo'
|
||||
CLASS_PURPLE = 'purple'
|
||||
CLASS_PINK = 'pink'
|
||||
CLASS_RED = 'red'
|
||||
CLASS_ORANGE = 'orange'
|
||||
CLASS_YELLOW = 'yellow'
|
||||
CLASS_GREEN = 'green'
|
||||
CLASS_TEAL = 'teal'
|
||||
CLASS_CYAN = 'cyan'
|
||||
CLASS_GRAY = 'secondary'
|
||||
|
||||
CHOICES = (
|
||||
(CLASS_DEFAULT, 'Default'),
|
||||
(CLASS_PRIMARY, 'Primary (blue)'),
|
||||
(CLASS_SUCCESS, 'Success (green)'),
|
||||
(CLASS_INFO, 'Info (aqua)'),
|
||||
(CLASS_WARNING, 'Warning (orange)'),
|
||||
(CLASS_DANGER, 'Danger (red)'),
|
||||
(CLASS_LINK, 'None (link)'),
|
||||
(CLASS_LINK, 'Link'),
|
||||
(CLASS_BLUE, 'Blue'),
|
||||
(CLASS_INDIGO, 'Indigo'),
|
||||
(CLASS_PURPLE, 'Purple'),
|
||||
(CLASS_PINK, 'Pink'),
|
||||
(CLASS_RED, 'Red'),
|
||||
(CLASS_ORANGE, 'Orange'),
|
||||
(CLASS_YELLOW, 'Yellow'),
|
||||
(CLASS_GREEN, 'Green'),
|
||||
(CLASS_TEAL, 'Teal'),
|
||||
(CLASS_CYAN, 'Cyan'),
|
||||
(CLASS_GRAY, 'Gray'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ObjectChanges
|
||||
#
|
||||
|
||||
|
||||
class ObjectChangeActionChoices(ChoiceSet):
|
||||
|
||||
ACTION_CREATE = 'create'
|
||||
|
||||
@@ -2,7 +2,7 @@ from contextlib import contextmanager
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||
|
||||
from extras.signals import _handle_changed_object, _handle_deleted_object
|
||||
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
|
||||
from utilities.utils import curry
|
||||
from .webhooks import flush_webhooks
|
||||
|
||||
@@ -20,11 +20,13 @@ def change_logging(request):
|
||||
# Curry signals receivers to pass the current request
|
||||
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
|
||||
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
|
||||
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
yield
|
||||
|
||||
@@ -33,6 +35,7 @@ def change_logging(request):
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_webhooks(webhook_queue)
|
||||
|
||||
@@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
|
||||
CustomFieldTypeChoices.TYPE_DATE,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
if custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
self.lookup_expr = 'has_key'
|
||||
elif custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
@@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
#
|
||||
|
||||
class ContentTypeFilterSet(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ['id', 'app_label', 'model']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
@@ -77,17 +77,25 @@ class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['type', 'content_types'],
|
||||
['weight', 'required'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields')
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=CustomFieldTypeChoices,
|
||||
required=False,
|
||||
widget=StaticSelectMultiple()
|
||||
widget=StaticSelectMultiple(),
|
||||
label=_('Field type')
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
@@ -117,6 +125,10 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
|
||||
('Templates', ('link_text', 'link_url')),
|
||||
)
|
||||
widgets = {
|
||||
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
help_texts = {
|
||||
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
|
||||
'Links which render as empty text will not be displayed.',
|
||||
@@ -167,12 +179,18 @@ class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
|
||||
field_groups = [
|
||||
['content_type'],
|
||||
['weight', 'new_window'],
|
||||
['q'],
|
||||
['content_type', 'weight', 'new_window'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields')
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
@@ -203,6 +221,9 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
('Template', ('template_code',)),
|
||||
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
widgets = {
|
||||
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
|
||||
class ExportTemplateCSVForm(CSVModelForm):
|
||||
@@ -252,15 +273,22 @@ class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
|
||||
field_groups = [
|
||||
['content_type', 'mime_type'],
|
||||
['file_extension', 'as_attachment'],
|
||||
['q'],
|
||||
['content_type', 'mime_type', 'file_extension', 'as_attachment'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields')
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
required=False
|
||||
required=False,
|
||||
label=_('MIME type')
|
||||
)
|
||||
file_extension = forms.CharField(
|
||||
required=False
|
||||
@@ -295,6 +323,10 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
)),
|
||||
('SSL', ('ssl_verification', 'ca_file_path')),
|
||||
)
|
||||
widgets = {
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
|
||||
class WebhookCSVForm(CSVModelForm):
|
||||
@@ -358,17 +390,25 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
class WebhookFilterForm(BootstrapMixin, forms.Form):
|
||||
field_groups = [
|
||||
['content_types', 'http_method'],
|
||||
['enabled', 'type_create', 'type_update', 'type_delete'],
|
||||
['q'],
|
||||
['content_types', 'http_method', 'enabled'],
|
||||
['type_create', 'type_update', 'type_delete'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields')
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
http_method = forms.MultipleChoiceField(
|
||||
choices=WebhookHttpMethodChoices,
|
||||
required=False,
|
||||
widget=StaticSelectMultiple()
|
||||
widget=StaticSelectMultiple(),
|
||||
label=_('HTTP method')
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -456,7 +496,11 @@ class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
|
||||
|
||||
# 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)
|
||||
key = cf_name[3:] # Strip "cf_" from field name
|
||||
value = self.cleaned_data.get(cf_name)
|
||||
empty_values = self.fields[cf_name].empty_values
|
||||
# Convert "empty" values to null
|
||||
self.instance.custom_field_data[key] = value if value not in empty_values else None
|
||||
|
||||
return super().clean()
|
||||
|
||||
@@ -495,12 +539,14 @@ class CustomFieldModelFilterForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
self.custom_field_filters = []
|
||||
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
)
|
||||
for cf in custom_fields:
|
||||
field_name = 'cf_{}'.format(cf.name)
|
||||
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
|
||||
self.custom_field_filters.append(field_name)
|
||||
|
||||
|
||||
#
|
||||
@@ -663,71 +709,84 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
|
||||
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
||||
field_order = [
|
||||
'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
|
||||
'tenant_group_id', 'tenant_id',
|
||||
]
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['device_type_id', 'role_id', 'platform_id'],
|
||||
['device_type_id', 'platform_id', 'role_id'],
|
||||
['cluster_group_id', 'cluster_id'],
|
||||
['tenant_group_id', 'tenant_id', 'tag']
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Regions')
|
||||
label=_('Regions'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site groups')
|
||||
label=_('Site groups'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Sites')
|
||||
label=_('Sites'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device types')
|
||||
label=_('Device types'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Roles')
|
||||
label=_('Roles'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
platform_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
label=_('Platforms')
|
||||
label=_('Platforms'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
cluster_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster groups')
|
||||
label=_('Cluster groups'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
label=_('Clusters')
|
||||
label=_('Clusters'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tenant_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant groups')
|
||||
label=_('Tenant groups'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant')
|
||||
label=_('Tenant'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
label=_('Tags')
|
||||
label=_('Tags'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
|
||||
|
||||
@@ -801,9 +860,15 @@ class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
model = JournalEntry
|
||||
field_groups = [
|
||||
['q'],
|
||||
['created_before', 'created_after', 'created_by_id'],
|
||||
['assigned_object_type_id', 'kind']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
created_after = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('After'),
|
||||
@@ -820,7 +885,8 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -828,7 +894,8 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
@@ -844,9 +911,15 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
model = ObjectChange
|
||||
field_groups = [
|
||||
['q'],
|
||||
['time_before', 'time_after', 'action'],
|
||||
['user_id', 'changed_object_type_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
time_after = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('After'),
|
||||
@@ -868,7 +941,8 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -876,7 +950,8 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
|
||||
|
||||
|
||||
53
netbox/extras/graphql/mixins.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import graphene
|
||||
from graphene.types.generic import GenericScalar
|
||||
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
'ConfigContextMixin',
|
||||
'CustomFieldsMixin',
|
||||
'ImageAttachmentsMixin',
|
||||
'JournalEntriesMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
|
||||
|
||||
class ChangelogMixin:
|
||||
changelog = graphene.List('extras.graphql.types.ObjectChangeType')
|
||||
|
||||
def resolve_changelog(self, info):
|
||||
return self.object_changes.restrict(info.context.user, 'view')
|
||||
|
||||
|
||||
class ConfigContextMixin:
|
||||
config_context = GenericScalar()
|
||||
|
||||
def resolve_config_context(self, info):
|
||||
return self.get_config_context()
|
||||
|
||||
|
||||
class CustomFieldsMixin:
|
||||
custom_fields = GenericScalar()
|
||||
|
||||
def resolve_custom_fields(self, info):
|
||||
return self.custom_field_data
|
||||
|
||||
|
||||
class ImageAttachmentsMixin:
|
||||
image_attachments = graphene.List('extras.graphql.types.ImageAttachmentType')
|
||||
|
||||
def resolve_image_attachments(self, info):
|
||||
return self.images.restrict(info.context.user, 'view')
|
||||
|
||||
|
||||
class JournalEntriesMixin:
|
||||
journal_entries = graphene.List('extras.graphql.types.JournalEntryType')
|
||||
|
||||
def resolve_journal_entries(self, info):
|
||||
return self.journal_entries.restrict(info.context.user, 'view')
|
||||
|
||||
|
||||
class TagsMixin:
|
||||
tags = graphene.List('extras.graphql.types.TagType')
|
||||
|
||||
def resolve_tags(self, info):
|
||||
return self.tags.all()
|
||||
@@ -1,5 +1,5 @@
|
||||
from extras import filtersets, models
|
||||
from netbox.graphql.types import BaseObjectType
|
||||
from netbox.graphql.types import BaseObjectType, ObjectType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextType',
|
||||
@@ -8,12 +8,13 @@ __all__ = (
|
||||
'ExportTemplateType',
|
||||
'ImageAttachmentType',
|
||||
'JournalEntryType',
|
||||
'ObjectChangeType',
|
||||
'TagType',
|
||||
'WebhookType',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextType(BaseObjectType):
|
||||
class ConfigContextType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConfigContext
|
||||
@@ -21,7 +22,7 @@ class ConfigContextType(BaseObjectType):
|
||||
filterset_class = filtersets.ConfigContextFilterSet
|
||||
|
||||
|
||||
class CustomFieldType(BaseObjectType):
|
||||
class CustomFieldType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomField
|
||||
@@ -29,7 +30,7 @@ class CustomFieldType(BaseObjectType):
|
||||
filterset_class = filtersets.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomLinkType(BaseObjectType):
|
||||
class CustomLinkType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomLink
|
||||
@@ -37,7 +38,7 @@ class CustomLinkType(BaseObjectType):
|
||||
filterset_class = filtersets.CustomLinkFilterSet
|
||||
|
||||
|
||||
class ExportTemplateType(BaseObjectType):
|
||||
class ExportTemplateType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ExportTemplate
|
||||
@@ -53,7 +54,7 @@ class ImageAttachmentType(BaseObjectType):
|
||||
filterset_class = filtersets.ImageAttachmentFilterSet
|
||||
|
||||
|
||||
class JournalEntryType(BaseObjectType):
|
||||
class JournalEntryType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.JournalEntry
|
||||
@@ -61,7 +62,15 @@ class JournalEntryType(BaseObjectType):
|
||||
filterset_class = filtersets.JournalEntryFilterSet
|
||||
|
||||
|
||||
class TagType(BaseObjectType):
|
||||
class ObjectChangeType(BaseObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ObjectChange
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
|
||||
class TagType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
@@ -69,7 +78,7 @@ class TagType(BaseObjectType):
|
||||
filterset_class = filtersets.TagFilterSet
|
||||
|
||||
|
||||
class WebhookType(BaseObjectType):
|
||||
class WebhookType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Webhook
|
||||
|
||||
@@ -37,12 +37,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(b'Webhook received!\n')
|
||||
|
||||
request_counter += 1
|
||||
|
||||
# Print the request headers to stdout
|
||||
# Print the request headers
|
||||
if self.show_headers:
|
||||
for k, v in self.headers.items():
|
||||
print('{}: {}'.format(k, v))
|
||||
print(f'{k}: {v}')
|
||||
print()
|
||||
|
||||
# Print the request body (if any)
|
||||
@@ -55,8 +53,11 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
print('(No body)')
|
||||
|
||||
print(f'Completed request #{request_counter}')
|
||||
print('------------')
|
||||
|
||||
request_counter += 1
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Start a simple listener to display received HTTP requests"
|
||||
|
||||
26
netbox/extras/migrations/0062_clear_secrets_changelog.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_secrets_changelog(apps, schema_editor):
|
||||
"""
|
||||
Delete all ObjectChange records referencing a model within the old secrets app (pre-v3.0).
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
ObjectChange = apps.get_model('extras', 'ObjectChange')
|
||||
|
||||
content_type_ids = ContentType.objects.filter(app_label='secrets').values_list('id', flat=True)
|
||||
ObjectChange.objects.filter(changed_object_type__in=content_type_ids).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0061_extras_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=clear_secrets_changelog,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -35,7 +35,6 @@ class CustomField(ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
verbose_name='Object(s)',
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
help_text='The object(s) to which this field applies.'
|
||||
)
|
||||
@@ -125,6 +124,30 @@ class CustomField(ChangeLoggedModel):
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
b) the assignment of an existing CustomField to new object types.
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
|
||||
for instance in instances:
|
||||
instance.custom_field_data[self.name] = self.default
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
no longer assigned to a model, or because it has been deleted).
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
for instance in instances:
|
||||
del(instance.custom_field_data[self.name])
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
@@ -137,17 +160,6 @@ class CustomField(ChangeLoggedModel):
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
no longer assigned to a model, or because it has been deleted).
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
|
||||
del(obj.custom_field_data[self.name])
|
||||
obj.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ class Webhook(ChangeLoggedModel):
|
||||
blank=True,
|
||||
help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
|
||||
"Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
|
||||
"support with the same context as the request body (below)."
|
||||
"supported with the same context as the request body (below)."
|
||||
)
|
||||
body_template = models.TextField(
|
||||
blank=True,
|
||||
@@ -249,7 +249,8 @@ class ExportTemplate(ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
template_code = models.TextField(
|
||||
help_text='The list of objects being exported is passed as a context variable named <code>queryset</code>.'
|
||||
help_text='Jinja2 template code. The list of objects being exported is passed as a context variable named '
|
||||
'<code>queryset</code>.'
|
||||
)
|
||||
mime_type = models.CharField(
|
||||
max_length=50,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.dispatch import receiver, Signal
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from prometheus_client import Counter
|
||||
|
||||
from netbox.signals import post_clean
|
||||
from .choices import ObjectChangeActionChoices
|
||||
@@ -15,6 +16,10 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
# Change logging/webhooks
|
||||
#
|
||||
|
||||
# Define a custom signal that can be sent to clear any queued webhooks
|
||||
clear_webhooks = Signal()
|
||||
|
||||
|
||||
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
@@ -95,10 +100,28 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
|
||||
"""
|
||||
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
|
||||
"""
|
||||
logger = logging.getLogger('webhooks')
|
||||
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
|
||||
|
||||
webhook_queue.clear()
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
def handle_cf_added_obj_types(instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
Handle the population of default/null values when a CustomField is added to one or more ContentTypes.
|
||||
"""
|
||||
if action == 'post_add':
|
||||
instance.populate_initial_data(ContentType.objects.filter(pk__in=pk_set))
|
||||
|
||||
|
||||
def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
|
||||
@@ -122,9 +145,10 @@ def handle_cf_deleted(instance, **kwargs):
|
||||
instance.remove_stale_data(instance.content_types.all())
|
||||
|
||||
|
||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||
post_save.connect(handle_cf_renamed, sender=CustomField)
|
||||
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
||||
m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through)
|
||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -2,7 +2,8 @@ import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
|
||||
ToggleColumn,
|
||||
)
|
||||
from .models import *
|
||||
|
||||
@@ -37,14 +38,16 @@ class CustomFieldTable(BaseTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
content_types = ContentTypesColumn()
|
||||
required = BooleanColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices',
|
||||
'pk', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', 'description',
|
||||
'filter_logic', 'choices',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'required', 'description')
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -98,19 +101,30 @@ class WebhookTable(BaseTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
content_types = ContentTypesColumn()
|
||||
enabled = BooleanColumn()
|
||||
type_create = BooleanColumn()
|
||||
type_update = BooleanColumn()
|
||||
type_delete = BooleanColumn()
|
||||
type_create = BooleanColumn(
|
||||
verbose_name='Create'
|
||||
)
|
||||
type_update = BooleanColumn(
|
||||
verbose_name='Update'
|
||||
)
|
||||
type_delete = BooleanColumn(
|
||||
verbose_name='Delete'
|
||||
)
|
||||
ssl_validation = BooleanColumn(
|
||||
verbose_name='SSL Validation'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
|
||||
'secret', 'ssl_validation', 'ca_file_path',
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ from utilities.utils import render_jinja2
|
||||
|
||||
register = template.Library()
|
||||
|
||||
LINK_BUTTON = '<a href="{}"{} class="btn btn-sm btn-{} m-1">{}</a>\n'
|
||||
LINK_BUTTON = '<a href="{}"{} class="btn btn-sm btn-{}">{}</a>\n'
|
||||
|
||||
GROUP_BUTTON = """
|
||||
<div class="dropdown m-1">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm btn-{} dropdown-toggle"
|
||||
type="button"
|
||||
|
||||
@@ -42,8 +42,11 @@ class CustomFieldTest(TestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Assign a value to the first Site
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = data['field_value']
|
||||
site.save()
|
||||
|
||||
@@ -73,8 +76,11 @@ class CustomFieldTest(TestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Assign a value to the first Site
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = 'Option A'
|
||||
site.save()
|
||||
|
||||
@@ -675,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Selection filtering
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Multiselect filtering
|
||||
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
@@ -689,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
|
||||
'cf6': 'http://foo.example.com/',
|
||||
'cf7': 'http://foo.example.com/',
|
||||
'cf8': 'Foo',
|
||||
'cf9': ['A', 'B'],
|
||||
}),
|
||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||
'cf1': 200,
|
||||
@@ -699,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
|
||||
'cf6': 'http://bar.example.com/',
|
||||
'cf7': 'http://bar.example.com/',
|
||||
'cf8': 'Bar',
|
||||
'cf9': ['AA', 'B'],
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
@@ -724,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)
|
||||
|
||||
53
netbox/extras/tests/test_forms.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
class CustomFieldModelFormTest(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
CHOICES = ('A', 'B', 'C')
|
||||
|
||||
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
||||
cf_text.content_types.set([obj_type])
|
||||
|
||||
cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||
cf_integer.content_types.set([obj_type])
|
||||
|
||||
cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
|
||||
cf_boolean.content_types.set([obj_type])
|
||||
|
||||
cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
|
||||
cf_date.content_types.set([obj_type])
|
||||
|
||||
cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
|
||||
cf_url.content_types.set([obj_type])
|
||||
|
||||
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
|
||||
cf_select.content_types.set([obj_type])
|
||||
|
||||
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=CHOICES)
|
||||
cf_multiselect.content_types.set([obj_type])
|
||||
|
||||
def test_empty_values(self):
|
||||
"""
|
||||
Test that empty custom field values are stored as null
|
||||
"""
|
||||
form = SiteForm({
|
||||
'name': 'Site 1',
|
||||
'slug': 'site-1',
|
||||
'status': 'active',
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
instance = form.save()
|
||||
|
||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||
self.assertIn(field_type, instance.custom_field_data)
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
@@ -75,13 +75,13 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"name,content_type,weight,button_class,link_text,link_url",
|
||||
"Custom Link 4,dcim.site,100,primary,Link 4,http://exmaple.com/?4",
|
||||
"Custom Link 5,dcim.site,100,primary,Link 5,http://exmaple.com/?5",
|
||||
"Custom Link 6,dcim.site,100,primary,Link 6,http://exmaple.com/?6",
|
||||
"Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4",
|
||||
"Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5",
|
||||
"Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'button_class': CustomLinkButtonClassChoices.CLASS_INFO,
|
||||
'button_class': CustomLinkButtonClassChoices.CLASS_CYAN,
|
||||
'weight': 200,
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ class AvailableIPsMixin:
|
||||
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
|
||||
'request': request,
|
||||
'parent': parent,
|
||||
'vrf': parent.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -331,7 +331,7 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
return OrderedDict([
|
||||
('family', self.context['parent'].family),
|
||||
('address', f"{instance}/{self.context['parent'].mask_length}"),
|
||||
('vrf', self.context['parent'].vrf),
|
||||
('vrf', vrf),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
children = MultiValueNumberFilter(
|
||||
field_name='_children'
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='net_mask_length'
|
||||
)
|
||||
|
||||
@@ -4,15 +4,16 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldModelFilterForm,
|
||||
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
|
||||
CustomFieldModelFilterForm,
|
||||
)
|
||||
from extras.models import Tag
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField,
|
||||
CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
|
||||
NumericArrayField, ReturnURLForm, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
|
||||
CSVContentTypeField, CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
ExpandableIPAddressField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||
@@ -106,21 +107,27 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
|
||||
|
||||
class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = VRF
|
||||
field_order = ['import_target_id', 'export_target_id', 'tenant_group_id', 'tenant_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['import_target_id', 'export_target_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['tag']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
import_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Import targets')
|
||||
label=_('Import targets'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
export_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Export targets')
|
||||
label=_('Export targets'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -140,6 +147,10 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
fields = [
|
||||
'name', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
]
|
||||
fieldsets = (
|
||||
('Route Target', ('name', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
|
||||
class RouteTargetCSVForm(CustomFieldModelCSVForm):
|
||||
@@ -177,20 +188,27 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
|
||||
|
||||
class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = RouteTarget
|
||||
field_order = ['name', 'tenant_group_id', 'tenant_id', 'importing_vrfs', 'exporting_vrfs']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['importing_vrf_id', 'exporting_vrf_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
importing_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Imported by VRF')
|
||||
label=_('Imported by VRF'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
exporting_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Exported by VRF')
|
||||
label=_('Exported by VRF'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -331,11 +349,16 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
|
||||
class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Aggregate
|
||||
field_order = ['family', 'rir', 'tenant_group_id', 'tenant_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['family', 'rir_id'],
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||
@@ -345,7 +368,8 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
label=_('RIR')
|
||||
label=_('RIR'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -467,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class PrefixCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -604,16 +623,18 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Prefix
|
||||
field_order = [
|
||||
'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id',
|
||||
'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', 'mark_utilized',
|
||||
]
|
||||
field_groups = [
|
||||
['role_id', 'within_include', 'family', 'mask_length'],
|
||||
['vrf_id', 'present_in_vrf_id', 'is_pool', 'mark_utilized'],
|
||||
['q', 'tag'],
|
||||
['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'],
|
||||
['vrf_id', 'present_in_vrf_id'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id', 'status', 'tag']
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
@@ -632,22 +653,24 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
label=_('Address family'),
|
||||
widget=StaticSelect()
|
||||
)
|
||||
mask_length = forms.ChoiceField(
|
||||
mask_length = forms.MultipleChoiceField(
|
||||
required=False,
|
||||
choices=PREFIX_MASK_LENGTH_CHOICES,
|
||||
label=_('Mask length'),
|
||||
widget=StaticSelect()
|
||||
widget=StaticSelectMultiple()
|
||||
)
|
||||
vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global'
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
)
|
||||
present_in_vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Present in VRF')
|
||||
label=_('Present in VRF'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
@@ -657,12 +680,14 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -671,13 +696,15 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site')
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role')
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
is_pool = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -728,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class IPRangeCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -801,13 +823,16 @@ class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
|
||||
|
||||
class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = IPRange
|
||||
field_order = [
|
||||
'family', 'vrf_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
|
||||
]
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['family', 'vrf_id', 'status', 'role_id'],
|
||||
['tenant_group_id', 'tenant_id', 'tag'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||
@@ -818,7 +843,8 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global'
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
@@ -829,7 +855,8 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role')
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -838,7 +865,7 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -989,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize primary_for_parent if IP address is already assigned
|
||||
if self.instance.pk and self.instance.assigned_object:
|
||||
parent = self.instance.assigned_object.parent_object
|
||||
@@ -1065,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'role': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -1219,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label='VRF',
|
||||
empty_label='Global'
|
||||
label='VRF'
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -1231,15 +1251,20 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = IPAddress
|
||||
field_order = [
|
||||
'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
|
||||
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
|
||||
'assigned_to_interface', 'tenant_group_id', 'tenant_id',
|
||||
]
|
||||
field_groups = [
|
||||
['parent', 'family', 'mask_length'],
|
||||
['status', 'vrf_id', 'present_in_vrf_id'],
|
||||
['role', 'assigned_to_interface'],
|
||||
['q', 'tag'],
|
||||
['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
|
||||
['vrf_id', 'present_in_vrf_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -1265,12 +1290,14 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global'
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
)
|
||||
present_in_vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Present in VRF')
|
||||
label=_('Present in VRF'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=IPAddressStatusChoices,
|
||||
@@ -1369,6 +1396,10 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
|
||||
'clustergroup', 'cluster',
|
||||
]
|
||||
fieldsets = (
|
||||
('VLAN Group', ('name', 'slug', 'description')),
|
||||
('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
|
||||
)
|
||||
widgets = {
|
||||
'scope_type': StaticSelect,
|
||||
}
|
||||
@@ -1396,17 +1427,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
|
||||
class VLANGroupCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned site'
|
||||
)
|
||||
slug = SlugField()
|
||||
scope_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
label='Scope type (app & model)'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ('name', 'slug', 'scope_type', 'scope_id', 'description')
|
||||
labels = {
|
||||
'scope_id': 'Scope ID',
|
||||
}
|
||||
|
||||
|
||||
class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
@@ -1429,37 +1462,43 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
|
||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
field_groups = [
|
||||
['region', 'sitegroup', 'site'],
|
||||
['location', 'rack']
|
||||
['q'],
|
||||
['region', 'sitegroup', 'site', 'location', 'rack']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
sitegroup = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Site')
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
location = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location')
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
rack = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack')
|
||||
label=_('Rack'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
|
||||
|
||||
@@ -1641,23 +1680,28 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = VLAN
|
||||
field_order = [
|
||||
'region_id', 'site_group_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
|
||||
]
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['group_id', 'role_id', 'status'],
|
||||
['group_id', 'status', 'role_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1666,7 +1710,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
query_params={
|
||||
'region': '$region'
|
||||
},
|
||||
label=_('Site')
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
@@ -1675,7 +1720,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
query_params={
|
||||
'region': '$region'
|
||||
},
|
||||
label=_('VLAN group')
|
||||
label=_('VLAN group'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=VLANStatusChoices,
|
||||
@@ -1686,7 +1732,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role')
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1740,6 +1787,15 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = Service
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('protocol', 'port'),
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
protocol = forms.ChoiceField(
|
||||
choices=add_blank_choice(ServiceProtocolChoices),
|
||||
required=False,
|
||||
|
||||
20
netbox/ipam/graphql/mixins.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import graphene
|
||||
|
||||
__all__ = (
|
||||
'IPAddressesMixin',
|
||||
'VLANGroupsMixin',
|
||||
)
|
||||
|
||||
|
||||
class IPAddressesMixin:
|
||||
ip_addresses = graphene.List('ipam.graphql.types.IPAddressType')
|
||||
|
||||
def resolve_ip_addresses(self, info):
|
||||
return self.ip_addresses.restrict(info.context.user, 'view')
|
||||
|
||||
|
||||
class VLANGroupsMixin:
|
||||
vlan_groups = graphene.List('ipam.graphql.types.VLANGroupType')
|
||||
|
||||
def resolve_vlan_groups(self, info):
|
||||
return self.vlan_groups.restrict(info.context.user, 'view')
|
||||
@@ -1,5 +1,5 @@
|
||||
from ipam import filtersets, models
|
||||
from netbox.graphql.types import ObjectType, TaggedObjectType
|
||||
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
|
||||
|
||||
__all__ = (
|
||||
'AggregateType',
|
||||
@@ -16,7 +16,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class AggregateType(TaggedObjectType):
|
||||
class AggregateType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
@@ -24,7 +24,7 @@ class AggregateType(TaggedObjectType):
|
||||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
class IPAddressType(TaggedObjectType):
|
||||
class IPAddressType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.IPAddress
|
||||
@@ -35,7 +35,7 @@ class IPAddressType(TaggedObjectType):
|
||||
return self.role or None
|
||||
|
||||
|
||||
class IPRangeType(TaggedObjectType):
|
||||
class IPRangeType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.IPRange
|
||||
@@ -46,7 +46,7 @@ class IPRangeType(TaggedObjectType):
|
||||
return self.role or None
|
||||
|
||||
|
||||
class PrefixType(TaggedObjectType):
|
||||
class PrefixType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
@@ -54,7 +54,7 @@ class PrefixType(TaggedObjectType):
|
||||
filterset_class = filtersets.PrefixFilterSet
|
||||
|
||||
|
||||
class RIRType(ObjectType):
|
||||
class RIRType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RIR
|
||||
@@ -62,7 +62,7 @@ class RIRType(ObjectType):
|
||||
filterset_class = filtersets.RIRFilterSet
|
||||
|
||||
|
||||
class RoleType(ObjectType):
|
||||
class RoleType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Role
|
||||
@@ -70,7 +70,7 @@ class RoleType(ObjectType):
|
||||
filterset_class = filtersets.RoleFilterSet
|
||||
|
||||
|
||||
class RouteTargetType(TaggedObjectType):
|
||||
class RouteTargetType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.RouteTarget
|
||||
@@ -78,7 +78,7 @@ class RouteTargetType(TaggedObjectType):
|
||||
filterset_class = filtersets.RouteTargetFilterSet
|
||||
|
||||
|
||||
class ServiceType(TaggedObjectType):
|
||||
class ServiceType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Service
|
||||
@@ -86,7 +86,7 @@ class ServiceType(TaggedObjectType):
|
||||
filterset_class = filtersets.ServiceFilterSet
|
||||
|
||||
|
||||
class VLANType(TaggedObjectType):
|
||||
class VLANType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VLAN
|
||||
@@ -94,7 +94,7 @@ class VLANType(TaggedObjectType):
|
||||
filterset_class = filtersets.VLANFilterSet
|
||||
|
||||
|
||||
class VLANGroupType(ObjectType):
|
||||
class VLANGroupType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VLANGroup
|
||||
@@ -102,7 +102,7 @@ class VLANGroupType(ObjectType):
|
||||
filterset_class = filtersets.VLANGroupFilterSet
|
||||
|
||||
|
||||
class VRFType(TaggedObjectType):
|
||||
class VRFType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VRF
|
||||
|
||||
@@ -151,7 +151,7 @@ class NetHostContained(Lookup):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
|
||||
return 'CAST(HOST(%s) AS INET) <<= %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetFamily(Transform):
|
||||
|
||||
@@ -163,7 +163,9 @@ class Aggregate(PrimaryModel):
|
||||
"""
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
@@ -394,6 +396,16 @@ class Prefix(PrimaryModel):
|
||||
else:
|
||||
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
|
||||
|
||||
def get_child_ranges(self):
|
||||
"""
|
||||
Return all IPRanges within this Prefix and VRF.
|
||||
"""
|
||||
return IPRange.objects.filter(
|
||||
vrf=self.vrf,
|
||||
start_address__net_host_contained=str(self.prefix),
|
||||
end_address__net_host_contained=str(self.prefix)
|
||||
)
|
||||
|
||||
def get_child_ips(self):
|
||||
"""
|
||||
Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
|
||||
@@ -423,7 +435,10 @@ class Prefix(PrimaryModel):
|
||||
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||
available_ips = prefix - child_ips
|
||||
child_ranges = netaddr.IPSet()
|
||||
for iprange in self.get_child_ranges():
|
||||
child_ranges.add(iprange.range)
|
||||
available_ips = prefix - child_ips - child_ranges
|
||||
|
||||
# IPv6, pool, or IPv4 /31-/32 sets are fully usable
|
||||
if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
|
||||
@@ -469,14 +484,21 @@ class Prefix(PrimaryModel):
|
||||
vrf=self.vrf
|
||||
)
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
else:
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||
child_ips = netaddr.IPSet()
|
||||
for iprange in self.get_child_ranges():
|
||||
child_ips.add(iprange.range)
|
||||
for ip in self.get_child_ips():
|
||||
child_ips.add(ip.address.ip)
|
||||
|
||||
prefix_size = self.prefix.size
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
return int(float(child_count) / prefix_size * 100)
|
||||
utilization = int(float(child_ips.size) / prefix_size * 100)
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
@@ -589,6 +611,10 @@ class IPRange(PrimaryModel):
|
||||
def family(self):
|
||||
return self.start_address.version if self.start_address else None
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
return netaddr.IPRange(self.start_address.ip, self.end_address.ip)
|
||||
|
||||
@property
|
||||
def mask_length(self):
|
||||
return self.start_address.prefixlen if self.start_address else None
|
||||
@@ -797,18 +823,15 @@ class IPAddress(PrimaryModel):
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk:
|
||||
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if device:
|
||||
if getattr(self.assigned_object, 'device', None) != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but not assigned to it!"
|
||||
})
|
||||
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if vm:
|
||||
if getattr(self.assigned_object, 'virtual_machine', None) != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
|
||||
})
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr, None) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
|
||||
@@ -15,12 +15,25 @@ AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% load helpers %}
|
||||
{% for i in record.depth|as_range %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
{% if record.depth %}
|
||||
<div class="record-depth">
|
||||
{% for i in record.depth|as_range %}
|
||||
<span>•</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
"""
|
||||
|
||||
PREFIXFLAT_LINK = """
|
||||
{% load helpers %}
|
||||
{% if record.pk %}
|
||||
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
@@ -277,10 +290,10 @@ class PrefixTable(BaseTable):
|
||||
template_code=PREFIX_LINK,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
prefix_flat = tables.Column(
|
||||
accessor=Accessor('prefix'),
|
||||
linkify=True,
|
||||
verbose_name='Prefix (Flat)'
|
||||
prefix_flat = tables.TemplateColumn(
|
||||
template_code=PREFIXFLAT_LINK,
|
||||
attrs={'td': {'class': 'text-nowrap'}},
|
||||
verbose_name='Prefix (Flat)',
|
||||
)
|
||||
depth = tables.Column(
|
||||
accessor=Accessor('_depth'),
|
||||
@@ -544,7 +557,7 @@ class VLANTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
}
|
||||
@@ -562,8 +575,8 @@ class VLANDetailTable(VLANTable):
|
||||
)
|
||||
|
||||
class Meta(VLANTable.Meta):
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMembersTable(BaseTable):
|
||||
|
||||
@@ -216,9 +216,10 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test retrieval of all available prefixes within a parent prefix.
|
||||
"""
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'))
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vrf=vrf)
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'), vrf=vrf)
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'), vrf=vrf)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix')
|
||||
|
||||
@@ -232,7 +233,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test retrieval of the first available prefix within a parent prefix.
|
||||
"""
|
||||
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.add_prefix')
|
||||
@@ -269,17 +270,18 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test the creation of available prefixes within a parent prefix.
|
||||
"""
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
# Try to create five /30s (only four are available)
|
||||
data = [
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 1'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 2'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 3'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 4'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 5'},
|
||||
{'prefix_length': 30, 'description': 'Prefix 1'},
|
||||
{'prefix_length': 30, 'description': 'Prefix 2'},
|
||||
{'prefix_length': 30, 'description': 'Prefix 3'},
|
||||
{'prefix_length': 30, 'description': 'Prefix 4'},
|
||||
{'prefix_length': 30, 'description': 'Prefix 5'},
|
||||
]
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
@@ -299,7 +301,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test retrieval of all available IP addresses within a parent prefix.
|
||||
"""
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress')
|
||||
|
||||
@@ -318,7 +321,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test retrieval of the first available IP address within a parent prefix.
|
||||
"""
|
||||
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
|
||||
@@ -342,7 +345,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Test the creation of available IP addresses within a parent prefix.
|
||||
"""
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
params = {'mask_length': ['24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vrf(self):
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, VLAN, VLANGroup, VRF
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
class TestAggregate(TestCase):
|
||||
@@ -72,6 +72,23 @@ class TestPrefix(TestCase):
|
||||
# VRF container is limited to its own VRF
|
||||
self.assertSetEqual(child_prefix_pks, {prefixes[2].pk})
|
||||
|
||||
def test_get_child_ranges(self):
|
||||
prefix = Prefix(prefix='192.168.0.16/28')
|
||||
prefix.save()
|
||||
ranges = IPRange.objects.bulk_create((
|
||||
IPRange(start_address=IPNetwork('192.168.0.1/24'), end_address=IPNetwork('192.168.0.10/24'), size=10), # No overlap
|
||||
IPRange(start_address=IPNetwork('192.168.0.11/24'), end_address=IPNetwork('192.168.0.17/24'), size=7), # Partial overlap
|
||||
IPRange(start_address=IPNetwork('192.168.0.18/24'), end_address=IPNetwork('192.168.0.23/24'), size=6), # Full overlap
|
||||
IPRange(start_address=IPNetwork('192.168.0.24/24'), end_address=IPNetwork('192.168.0.30/24'), size=7), # Full overlap
|
||||
IPRange(start_address=IPNetwork('192.168.0.31/24'), end_address=IPNetwork('192.168.0.40/24'), size=10), # Partial overlap
|
||||
))
|
||||
|
||||
child_ranges = prefix.get_child_ranges()
|
||||
|
||||
self.assertEqual(len(child_ranges), 2)
|
||||
self.assertEqual(child_ranges[0], ranges[2])
|
||||
self.assertEqual(child_ranges[1], ranges[3])
|
||||
|
||||
def test_get_child_ips(self):
|
||||
vrfs = VRF.objects.bulk_create((
|
||||
VRF(name='VRF 1'),
|
||||
@@ -125,17 +142,17 @@ class TestPrefix(TestCase):
|
||||
IPAddress(address=IPNetwork('10.0.0.3/26')),
|
||||
IPAddress(address=IPNetwork('10.0.0.5/26')),
|
||||
IPAddress(address=IPNetwork('10.0.0.7/26')),
|
||||
IPAddress(address=IPNetwork('10.0.0.9/26')),
|
||||
IPAddress(address=IPNetwork('10.0.0.11/26')),
|
||||
IPAddress(address=IPNetwork('10.0.0.13/26')),
|
||||
))
|
||||
IPRange.objects.create(
|
||||
start_address=IPNetwork('10.0.0.9/26'),
|
||||
end_address=IPNetwork('10.0.0.12/26')
|
||||
)
|
||||
missing_ips = IPSet([
|
||||
'10.0.0.2/32',
|
||||
'10.0.0.4/32',
|
||||
'10.0.0.6/32',
|
||||
'10.0.0.8/32',
|
||||
'10.0.0.10/32',
|
||||
'10.0.0.12/32',
|
||||
'10.0.0.13/32',
|
||||
'10.0.0.14/32',
|
||||
])
|
||||
available_ips = parent_prefix.get_available_ips()
|
||||
@@ -168,27 +185,30 @@ class TestPrefix(TestCase):
|
||||
IPAddress.objects.create(address=IPNetwork('10.0.0.4/24'))
|
||||
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24')
|
||||
|
||||
def test_get_utilization(self):
|
||||
|
||||
# Container Prefix
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('10.0.0.0/24'),
|
||||
status=PrefixStatusChoices.STATUS_CONTAINER
|
||||
)
|
||||
Prefix.objects.bulk_create((
|
||||
def test_get_utilization_container(self):
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),
|
||||
Prefix(prefix=IPNetwork('10.0.0.0/26')),
|
||||
Prefix(prefix=IPNetwork('10.0.0.128/26')),
|
||||
))
|
||||
self.assertEqual(prefix.get_utilization(), 50)
|
||||
|
||||
# Non-container Prefix
|
||||
prefix.status = PrefixStatusChoices.STATUS_ACTIVE
|
||||
prefix.save()
|
||||
IPAddress.objects.bulk_create(
|
||||
# Create 32 IPAddresses within the Prefix
|
||||
[IPAddress(address=IPNetwork('10.0.0.{}/24'.format(i))) for i in range(1, 33)]
|
||||
)
|
||||
self.assertEqual(prefix.get_utilization(), 12) # ~= 12%
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
self.assertEqual(prefixes[0].get_utilization(), 50) # 50% utilization
|
||||
|
||||
def test_get_utilization_noncontainer(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('10.0.0.0/24'),
|
||||
status=PrefixStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
|
||||
# Create 32 child IPs
|
||||
IPAddress.objects.bulk_create([
|
||||
IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
|
||||
])
|
||||
self.assertEqual(prefix.get_utilization(), 12) # 12.5% utilization
|
||||
|
||||
# Create a child range with 32 additional IPs
|
||||
IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
|
||||
self.assertEqual(prefix.get_utilization(), 25) # 25% utilization
|
||||
|
||||
#
|
||||
# Uniqueness enforcement tests
|
||||
|
||||
@@ -391,10 +391,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"VLAN Group 4,vlan-group-4,Fourth VLAN group",
|
||||
"VLAN Group 5,vlan-group-5,Fifth VLAN group",
|
||||
"VLAN Group 6,vlan-group-6,Sixth VLAN group",
|
||||
f"name,slug,scope_type,scope_id,description",
|
||||
f"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
|
||||
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
|
||||
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -77,6 +77,7 @@ urlpatterns = [
|
||||
path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
|
||||
path('prefixes/<int:pk>/journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}),
|
||||
path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
|
||||
path('prefixes/<int:pk>/ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'),
|
||||
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
|
||||
|
||||
# IP ranges
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from netbox.views import generic
|
||||
from utilities.forms import TableConfigForm
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
@@ -207,23 +208,6 @@ class AggregateListView(generic.ObjectListView):
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateDetailTable
|
||||
template_name = 'ipam/aggregate_list.html'
|
||||
|
||||
def extra_context(self):
|
||||
ipv4_total = 0
|
||||
ipv6_total = 0
|
||||
|
||||
for aggregate in self.queryset:
|
||||
if aggregate.prefix.version == 6:
|
||||
# Report equivalent /64s for IPv6 to keep things sane
|
||||
ipv6_total += int(aggregate.prefix.size / 2 ** 64)
|
||||
else:
|
||||
ipv4_total += aggregate.prefix.size
|
||||
|
||||
return {
|
||||
'ipv4_total': ipv4_total,
|
||||
'ipv6_total': ipv6_total,
|
||||
}
|
||||
|
||||
|
||||
class AggregateView(generic.ObjectView):
|
||||
@@ -412,30 +396,58 @@ class PrefixPrefixesView(generic.ObjectView):
|
||||
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
table = tables.PrefixDetailTable(child_prefixes, user=request.user)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.columns.show('pk')
|
||||
paginate_table(prefix_table, request)
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_prefix'),
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_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,
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'prefixes',
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
}
|
||||
|
||||
|
||||
class PrefixIPRangesView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix/ip_ranges.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Find all IPRanges belonging to this Prefix
|
||||
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
|
||||
|
||||
table = tables.IPRangeTable(ip_ranges, user=request.user)
|
||||
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'change': request.user.has_perm('ipam.change_iprange'),
|
||||
'delete': request.user.has_perm('ipam.delete_iprange'),
|
||||
}
|
||||
|
||||
return {
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'ip-ranges',
|
||||
}
|
||||
|
||||
|
||||
class PrefixIPAddressesView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
@@ -450,26 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView):
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
table = tables.IPAddressTable(ipaddresses, user=request.user)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
ip_table.columns.show('pk')
|
||||
paginate_table(ip_table, request)
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
}
|
||||
|
||||
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,
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'ip-addresses',
|
||||
'first_available_ip': instance.get_first_available_ip(),
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
}
|
||||
|
||||
@@ -778,7 +789,6 @@ class VLANGroupView(generic.ObjectView):
|
||||
class VLANGroupEditView(generic.ObjectEditView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
model_form = forms.VLANGroupForm
|
||||
template_name = 'ipam/vlangroup_edit.html'
|
||||
|
||||
|
||||
class VLANGroupDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
@@ -34,7 +34,6 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return list(queryset[self.offset:])
|
||||
|
||||
def get_limit(self, request):
|
||||
|
||||
if self.limit_query_param:
|
||||
try:
|
||||
limit = int(request.query_params[self.limit_query_param])
|
||||
|
||||
@@ -69,7 +69,7 @@ SECRET_KEY = ''
|
||||
# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of
|
||||
# application errors (assuming correct email settings are provided).
|
||||
ADMINS = [
|
||||
# ['John Doe', 'jdoe@example.com'],
|
||||
# ('John Doe', 'jdoe@example.com'),
|
||||
]
|
||||
|
||||
# URL schemes that are allowed within links in NetBox
|
||||
@@ -163,6 +163,10 @@ INTERNAL_IPS = ('127.0.0.1', '::1')
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
# Automatically reset the lifetime of a valid session upon each authenticated request. Enables users to remain
|
||||
# authenticated to NetBox indefinitely.
|
||||
LOGIN_PERSISTENCE = False
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
# are permitted to access most data in NetBox but not make any changes.
|
||||
LOGIN_REQUIRED = False
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import graphene
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from graphene.types.generic import GenericScalar
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, JournalEntriesMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'BaseObjectType',
|
||||
'ObjectType',
|
||||
'TaggedObjectType',
|
||||
'OrganizationalObjectType',
|
||||
'PrimaryObjectType',
|
||||
)
|
||||
|
||||
|
||||
@@ -27,30 +27,41 @@ class BaseObjectType(DjangoObjectType):
|
||||
return queryset.restrict(info.context.user, 'view')
|
||||
|
||||
|
||||
class ObjectType(BaseObjectType):
|
||||
class ObjectType(
|
||||
ChangelogMixin,
|
||||
BaseObjectType
|
||||
):
|
||||
"""
|
||||
Extends BaseObjectType with support for custom field data.
|
||||
Base GraphQL object type for unclassified models which support change logging
|
||||
"""
|
||||
custom_fields = GenericScalar()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def resolve_custom_fields(self, info):
|
||||
return self.custom_field_data
|
||||
|
||||
|
||||
class TaggedObjectType(ObjectType):
|
||||
class OrganizationalObjectType(
|
||||
ChangelogMixin,
|
||||
CustomFieldsMixin,
|
||||
BaseObjectType
|
||||
):
|
||||
"""
|
||||
Extends ObjectType with support for Tags
|
||||
Base type for organizational models
|
||||
"""
|
||||
tags = graphene.List(graphene.String)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def resolve_tags(self, info):
|
||||
return self.tags.all()
|
||||
|
||||
class PrimaryObjectType(
|
||||
ChangelogMixin,
|
||||
CustomFieldsMixin,
|
||||
JournalEntriesMixin,
|
||||
TagsMixin,
|
||||
BaseObjectType
|
||||
):
|
||||
"""
|
||||
Base type for primary models
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
#
|
||||
|
||||