mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-29 08:37:46 -06:00
Compare commits
483 Commits
v3.2.2
...
v3.3-beta2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11a6f0135 | ||
|
|
44850feaf8 | ||
|
|
367bf25618 | ||
|
|
b9678c7c0e | ||
|
|
37c4f1a7d3 | ||
|
|
d3a567a2f5 | ||
|
|
5b3ef04550 | ||
|
|
e96620260a | ||
|
|
29a611c729 | ||
|
|
562769fb89 | ||
|
|
4591237bfd | ||
|
|
262a0cf397 | ||
|
|
ff3fcb8134 | ||
|
|
d4d73674fc | ||
|
|
984d15d7fb | ||
|
|
efa449faff | ||
|
|
3af989763e | ||
|
|
9646f88384 | ||
|
|
1bbf5d214b | ||
|
|
8a075bcff9 | ||
|
|
9fe5f09742 | ||
|
|
84f2225f42 | ||
|
|
728ad51624 | ||
|
|
5ab03b7e92 | ||
|
|
6904666e2a | ||
|
|
890efa5400 | ||
|
|
04fb0bd51c | ||
|
|
2c43c8d077 | ||
|
|
c5fb7b72f0 | ||
|
|
07620db027 | ||
|
|
f8a3ffae4e | ||
|
|
62d1510c55 | ||
|
|
498b655cb7 | ||
|
|
fa94d9c82c | ||
|
|
6cee12b153 | ||
|
|
a6be8dccf5 | ||
|
|
466931d2fb | ||
|
|
d442f8fd60 | ||
|
|
7631722f97 | ||
|
|
6d30c07dd0 | ||
|
|
6f7289f932 | ||
|
|
2583abc39d | ||
|
|
12476036cd | ||
|
|
91070f823a | ||
|
|
8df4966a2b | ||
|
|
451a0067c7 | ||
|
|
e2580ea469 | ||
|
|
abf11fbcb8 | ||
|
|
383918d83b | ||
|
|
f8cbd322ba | ||
|
|
9835d6b2df | ||
|
|
17e00ac040 | ||
|
|
e92b7f8bb9 | ||
|
|
1c9db2d9f8 | ||
|
|
44586743ea | ||
|
|
802d9d2b6e | ||
|
|
a7a20ad2ea | ||
|
|
124ff23e3d | ||
|
|
abfa6a325a | ||
|
|
0e18292e41 | ||
|
|
6d53788ea2 | ||
|
|
fae9874dde | ||
|
|
b8da66bb55 | ||
|
|
8a2276e791 | ||
|
|
4bdef80554 | ||
|
|
1a028f77d4 | ||
|
|
7603468abc | ||
|
|
b854cefb57 | ||
|
|
58b191b439 | ||
|
|
3d475e5afa | ||
|
|
f385a5fd5e | ||
|
|
250265c3d9 | ||
|
|
e07dd3ddcb | ||
|
|
68f53aaa87 | ||
|
|
5fda5cc08c | ||
|
|
68b87dd668 | ||
|
|
6da171a699 | ||
|
|
024e7d8651 | ||
|
|
e8dd952aa5 | ||
|
|
fe2fae5b86 | ||
|
|
5b5160ca6f | ||
|
|
b9dd654e7a | ||
|
|
b0df24e6d1 | ||
|
|
868e94fb73 | ||
|
|
15395a56e7 | ||
|
|
29c81a788f | ||
|
|
0615252e15 | ||
|
|
be00d1f3c6 | ||
|
|
3eb6b6c07f | ||
|
|
0b86326435 | ||
|
|
fb2bfe2337 | ||
|
|
43a4a9c86d | ||
|
|
43b27cc052 | ||
|
|
53372a7471 | ||
|
|
1ddb219a0c | ||
|
|
2264937f81 | ||
|
|
8a8ada8529 | ||
|
|
123e758c6d | ||
|
|
531d961d30 | ||
|
|
c380fd00bf | ||
|
|
57397570c0 | ||
|
|
e106d7ac3a | ||
|
|
b72793a85a | ||
|
|
68f24755aa | ||
|
|
5a4467a4a8 | ||
|
|
7c109ffd8c | ||
|
|
6415661b61 | ||
|
|
ed7f42a803 | ||
|
|
e2af716a81 | ||
|
|
d3f91ce0a6 | ||
|
|
dde005366a | ||
|
|
85cab8d9b0 | ||
|
|
a49d3d2ddc | ||
|
|
93c30c94b3 | ||
|
|
1539769c08 | ||
|
|
c7ece43a18 | ||
|
|
69a22ffe5e | ||
|
|
e6bfde1397 | ||
|
|
bd60d46b82 | ||
|
|
3c2a55a521 | ||
|
|
29eb37857c | ||
|
|
6c9f2734a2 | ||
|
|
a40ab9ffb1 | ||
|
|
55b3e4eeb3 | ||
|
|
f386ec82ae | ||
|
|
ac4a87de13 | ||
|
|
42e5282283 | ||
|
|
11707cb3b1 | ||
|
|
d444aa1110 | ||
|
|
a858e041e4 | ||
|
|
13ef5dc0b3 | ||
|
|
9a7f3f8c1a | ||
|
|
1beb8522b9 | ||
|
|
af14bfdd84 | ||
|
|
ba079b9ee5 | ||
|
|
78a3dfc5d9 | ||
|
|
4d3278cb52 | ||
|
|
5fd3eb82cd | ||
|
|
1e1ce550c0 | ||
|
|
4bb4bbce14 | ||
|
|
29a46d2c18 | ||
|
|
878c465c56 | ||
|
|
f1c8926252 | ||
|
|
5bcc3a3fb9 | ||
|
|
30350e3b40 | ||
|
|
0004b834fb | ||
|
|
dbb1773e15 | ||
|
|
8e39e7f830 | ||
|
|
aa856e75e8 | ||
|
|
b1729f2127 | ||
|
|
13f854c91f | ||
|
|
277c2ff869 | ||
|
|
23f391c5b5 | ||
|
|
a5124ab9c8 | ||
|
|
a57398b0d6 | ||
|
|
c11af40a06 | ||
|
|
12c138b341 | ||
|
|
c6dfdf10e5 | ||
|
|
ad5fd01ea4 | ||
|
|
29f629156a | ||
|
|
be778353b7 | ||
|
|
3a6f46bf38 | ||
|
|
65f4895dd6 | ||
|
|
5b397a9827 | ||
|
|
dd6bfed565 | ||
|
|
6e983d1542 | ||
|
|
3be9f6c4f3 | ||
|
|
779969f150 | ||
|
|
2245f1bf41 | ||
|
|
a0f9b5e47b | ||
|
|
4649bc632c | ||
|
|
cdcb77dea8 | ||
|
|
c5770392e3 | ||
|
|
cd3111ca8d | ||
|
|
8e200a9cb4 | ||
|
|
ccb7e96d8a | ||
|
|
f75ddeb721 | ||
|
|
221ddc6d0f | ||
|
|
31c752bf3a | ||
|
|
03f1584d3a | ||
|
|
03d6e25dea | ||
|
|
8bbc592261 | ||
|
|
9d35a9d912 | ||
|
|
7622d90c1d | ||
|
|
fcd1daaf79 | ||
|
|
25ed3390cb | ||
|
|
7dd5f9e720 | ||
|
|
2077378ae1 | ||
|
|
f6c52e0616 | ||
|
|
fc02e15fb1 | ||
|
|
9b91c2a886 | ||
|
|
d8b40056b5 | ||
|
|
4315c4697c | ||
|
|
b77013c859 | ||
|
|
f7de2611c1 | ||
|
|
f9d81fd362 | ||
|
|
c330282919 | ||
|
|
db807ab4a6 | ||
|
|
d55e3c352a | ||
|
|
afec53cea3 | ||
|
|
6cb8b9110e | ||
|
|
12bd3840f9 | ||
|
|
dc05e62ce0 | ||
|
|
4f33685ca7 | ||
|
|
cfb9605e9b | ||
|
|
f563ba7a9e | ||
|
|
7e4b34560f | ||
|
|
d4db656940 | ||
|
|
3c15419bd0 | ||
|
|
e3b7bba84f | ||
|
|
a38a880e67 | ||
|
|
7043c6faf9 | ||
|
|
379880cd84 | ||
|
|
341615668b | ||
|
|
e4aa933d57 | ||
|
|
4587b83d85 | ||
|
|
9c214622a1 | ||
|
|
52178f78d1 | ||
|
|
440dfabefe | ||
|
|
64080e808e | ||
|
|
7decad1ff3 | ||
|
|
103729c085 | ||
|
|
4ced0bed13 | ||
|
|
0d6d68c62f | ||
|
|
278891c262 | ||
|
|
ae12948558 | ||
|
|
0c915f7de9 | ||
|
|
84f0561712 | ||
|
|
ba12db3019 | ||
|
|
575e2c443b | ||
|
|
7c09259b7d | ||
|
|
7ba268946a | ||
|
|
8074ca95bd | ||
|
|
d691ea92d0 | ||
|
|
903a3e1a9c | ||
|
|
4109113319 | ||
|
|
872c11502f | ||
|
|
10cb4f359a | ||
|
|
45babf162e | ||
|
|
3434428357 | ||
|
|
25128bd06f | ||
|
|
e7620b0dd0 | ||
|
|
65683d0df1 | ||
|
|
ff2ccfd670 | ||
|
|
81cea9b9d9 | ||
|
|
ae342a0506 | ||
|
|
f8221340af | ||
|
|
b1ec703ba9 | ||
|
|
3d785d836d | ||
|
|
7c79c90cd2 | ||
|
|
a6e285316a | ||
|
|
e6018cd38f | ||
|
|
92a6523bf3 | ||
|
|
2815eca260 | ||
|
|
896ebf01b1 | ||
|
|
a71b2e231b | ||
|
|
56f3aaf7c8 | ||
|
|
87a9cc0b9e | ||
|
|
972a1fdd14 | ||
|
|
723954f0d9 | ||
|
|
536239d272 | ||
|
|
d32bbd06cf | ||
|
|
5d868168a5 | ||
|
|
5d4575ed25 | ||
|
|
c04b4bbbfa | ||
|
|
cf76d5c46a | ||
|
|
e8b970608e | ||
|
|
83fdfaa0eb | ||
|
|
86c35a403a | ||
|
|
e96c382138 | ||
|
|
6876c9878e | ||
|
|
29a5fb041f | ||
|
|
8ef74192ec | ||
|
|
135ce93d03 | ||
|
|
f13b090b5c | ||
|
|
d1aa820856 | ||
|
|
9b51c2a0b2 | ||
|
|
87b3be26a0 | ||
|
|
a74dba865c | ||
|
|
c81c3d11ed | ||
|
|
180adb42a3 | ||
|
|
8d92ec2007 | ||
|
|
ecba9699ed | ||
|
|
36c65b7b22 | ||
|
|
8a4c808be5 | ||
|
|
6ed2dbf172 | ||
|
|
1b8350fe48 | ||
|
|
15080aad66 | ||
|
|
7a7f7c5dec | ||
|
|
c958208c47 | ||
|
|
9f4e565b8e | ||
|
|
bb2d21abdd | ||
|
|
0b0a646f87 | ||
|
|
e2eb7fdfb5 | ||
|
|
537383e071 | ||
|
|
bab6fb0de2 | ||
|
|
cf7a091319 | ||
|
|
3362bc3106 | ||
|
|
6befd2155a | ||
|
|
7b02070007 | ||
|
|
32322e95b6 | ||
|
|
3fbf1f7e71 | ||
|
|
9d308e6246 | ||
|
|
6c035eb13d | ||
|
|
b0a56a71bb | ||
|
|
201b9f635e | ||
|
|
f1d0d8e57a | ||
|
|
5838a9f3a0 | ||
|
|
998a392bd3 | ||
|
|
a0a87fc4c0 | ||
|
|
6c0b4c66c0 | ||
|
|
2c8a1ed69c | ||
|
|
fe899d9d7c | ||
|
|
6d3cded579 | ||
|
|
6280398bc1 | ||
|
|
1bd39e6568 | ||
|
|
bd950e9ca6 | ||
|
|
db42589cca | ||
|
|
b331f047af | ||
|
|
31024ce672 | ||
|
|
aea023357b | ||
|
|
2e5a5f71ba | ||
|
|
72516c00fb | ||
|
|
d34d5869be | ||
|
|
72726c784a | ||
|
|
662b02e2d8 | ||
|
|
a9ec1a7b4e | ||
|
|
f03c5037c4 | ||
|
|
a52c68f4c2 | ||
|
|
a73dda35e8 | ||
|
|
6e7c5dcaed | ||
|
|
c14a2a0a39 | ||
|
|
20eaa7d069 | ||
|
|
50c872c47c | ||
|
|
4ec26aa6aa | ||
|
|
a7d3e5e7f5 | ||
|
|
0570203891 | ||
|
|
7b5ff4c1a5 | ||
|
|
64146b8cb1 | ||
|
|
d155c39f59 | ||
|
|
a909ceda84 | ||
|
|
922916ae99 | ||
|
|
3b3247592e | ||
|
|
17292324a3 | ||
|
|
e5aa9d47f7 | ||
|
|
75eea50d71 | ||
|
|
9ef9443969 | ||
|
|
1505369133 | ||
|
|
9e1d8beaf0 | ||
|
|
17fb562740 | ||
|
|
2910aaeec0 | ||
|
|
aeef12cdc0 | ||
|
|
8ad203f97a | ||
|
|
aba4e03d3b | ||
|
|
6a99b36cce | ||
|
|
399afffddf | ||
|
|
906c3dca8b | ||
|
|
1b593384e5 | ||
|
|
951627093c | ||
|
|
6ff2e55ce4 | ||
|
|
3082c76ec6 | ||
|
|
4c51dbba80 | ||
|
|
f415d81049 | ||
|
|
24ff360ee0 | ||
|
|
2a4c728375 | ||
|
|
752a497218 | ||
|
|
1d4409c703 | ||
|
|
3a461d0279 | ||
|
|
594964aebe | ||
|
|
bb9eb119a4 | ||
|
|
4d5bcb65c8 | ||
|
|
c88f7b8408 | ||
|
|
8bc6d8cb23 | ||
|
|
eb4984afe6 | ||
|
|
d89f067c00 | ||
|
|
5667a9c456 | ||
|
|
b44bfa1aa6 | ||
|
|
304282bd4f | ||
|
|
93daa6406b | ||
|
|
ecee7421ea | ||
|
|
83fdab5feb | ||
|
|
e129ae50f0 | ||
|
|
f0b722b0a5 | ||
|
|
82706eb3a6 | ||
|
|
c22007939b | ||
|
|
f0fc8bf2cf | ||
|
|
c3742f63fd | ||
|
|
5d37f9f975 | ||
|
|
1f4ad444ae | ||
|
|
907323d46f | ||
|
|
e0fc1b4f41 | ||
|
|
4bb9b6ee26 | ||
|
|
6c290353c1 | ||
|
|
c48c8cc353 | ||
|
|
e208404e3a | ||
|
|
3c7c8c8776 | ||
|
|
bb2235b05e | ||
|
|
a6aec9ebac | ||
|
|
5f3695d2d0 | ||
|
|
ad12ad4a77 | ||
|
|
37903776fd | ||
|
|
c4c93ee346 | ||
|
|
72b2ab03cc | ||
|
|
4cefe26f80 | ||
|
|
991950650b | ||
|
|
8cc94689d8 | ||
|
|
01d2ede097 | ||
|
|
e2a02de6e9 | ||
|
|
ca10a7834a | ||
|
|
312d6c890e | ||
|
|
c146596564 | ||
|
|
6f5c2f1e29 | ||
|
|
1726593fb0 | ||
|
|
e8575495db | ||
|
|
cffc064a33 | ||
|
|
3dda7e2da2 | ||
|
|
22f1863475 | ||
|
|
bdb21da26e | ||
|
|
e759e123ac | ||
|
|
d858eceb38 | ||
|
|
af126fe7e3 | ||
|
|
124e93f737 | ||
|
|
fbd933b56a | ||
|
|
9c5355a300 | ||
|
|
491a4e7d78 | ||
|
|
39a9ebaeee | ||
|
|
9b4e016fe4 | ||
|
|
422ec7ecec | ||
|
|
a06a280534 | ||
|
|
1358469375 | ||
|
|
90d8395a2c | ||
|
|
11f7e3099d | ||
|
|
ef29bffb72 | ||
|
|
3effa37fa7 | ||
|
|
1493c920fd | ||
|
|
ea9258d36c | ||
|
|
db142061ff | ||
|
|
c536944a10 | ||
|
|
ae7ddecaa6 | ||
|
|
2e38e62101 | ||
|
|
2979a64ce3 | ||
|
|
bddca8e232 | ||
|
|
e9bf6a7bc5 | ||
|
|
9c3dfdfd14 | ||
|
|
c52aa2196d | ||
|
|
81c7fe2084 | ||
|
|
0301aec409 | ||
|
|
015bc48345 | ||
|
|
da1aabdfc1 | ||
|
|
c2fe2ba56f | ||
|
|
52b18393eb | ||
|
|
b172ae65d2 | ||
|
|
eab187fb6b | ||
|
|
502a14e820 | ||
|
|
7de27c69c0 | ||
|
|
f455f91ea3 | ||
|
|
bdaefc0e4d | ||
|
|
8040804c75 | ||
|
|
7cd840610b | ||
|
|
15e91908e8 | ||
|
|
0a9ba3b2e6 | ||
|
|
535606a185 | ||
|
|
25c266e4de | ||
|
|
977ccb01f2 | ||
|
|
c2a6a1c125 | ||
|
|
f6402a8b62 | ||
|
|
30d4097fd8 | ||
|
|
3fb967b482 | ||
|
|
61d756c7c4 | ||
|
|
9ae25e9449 | ||
|
|
9f3846ec5f | ||
|
|
7b5625a722 | ||
|
|
152d5a3b9a | ||
|
|
e2b32757d8 | ||
|
|
fa4807be8c | ||
|
|
086e34f728 | ||
|
|
2587720298 | ||
|
|
bc2491e6b7 | ||
|
|
69a1cc8759 | ||
|
|
098ef91583 | ||
|
|
17df8a5c43 | ||
|
|
4fac10ac4a | ||
|
|
03535ce50b |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.2
|
||||
placeholder: v3.3-beta2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.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: v3.2.2
|
||||
placeholder: v3.3-beta2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
7
.github/workflows/stale.yml
vendored
7
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
@@ -27,7 +27,10 @@ jobs:
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. NetBox
|
||||
is governed by a small group of core maintainers which means not all opened
|
||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
issues may receive direct feedback. **Do not** attempt to circumvent this
|
||||
process by "bumping" the issue; doing so will result in its immediate closure
|
||||
and you may be barred from participating in any future discussions. Please see
|
||||
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
stale-pr-label: 'pending closure'
|
||||
stale-pr-message: >
|
||||
This PR has been automatically marked as stale because it has not had
|
||||
|
||||
@@ -160,9 +160,9 @@ to aid in issue management.
|
||||
|
||||
It is natural that some new issues get more attention than others. The stale
|
||||
bot helps bring renewed attention to potentially valuable issues that may have
|
||||
been overlooked. **Do not** comment on an issue that has been marked stale in
|
||||
an effort to circumvent the bot: Doing so will not remove the stale label.
|
||||
(Stale labels can be removed only by maintainers.)
|
||||
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
||||
effort to circumvent the bot: This will result in the immediate closure of the
|
||||
issue, and you may be barred from participating in future discussions.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
|
||||
6
NOTICE
6
NOTICE
@@ -1 +1,7 @@
|
||||
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
|
||||
|
||||
This project contains code developed expressly for NetBox, and its reuse in
|
||||
other projects may introduce issues affecting performance, data integrity,
|
||||
and security.
|
||||
|
||||
For more information, please see https://github.com/netbox-community/netbox.
|
||||
|
||||
@@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [docs.netbox.dev](https://
|
||||
|
||||
[](https://ns1.com/)
|
||||
<br />
|
||||
[](https://sentry.io/)
|
||||
|
||||
[](https://stellar.tech/)
|
||||
|
||||
</div>
|
||||
|
||||
31
SECURITY.md
Normal file
31
SECURITY.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Security Policy
|
||||
|
||||
## No Warranty
|
||||
|
||||
Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release.
|
||||
|
||||
## Recommendations
|
||||
|
||||
Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as:
|
||||
|
||||
* Do not expose your NetBox installation to the public Internet
|
||||
* Do not permit multiple users to share an account
|
||||
* Enforce minimum password complexity requirements for local accounts
|
||||
* Prohibit access to your database from clients other than the NetBox application
|
||||
* Keep your deployment updated to the most recent stable release
|
||||
|
||||
## Reporting a Suspected Vulnerability
|
||||
|
||||
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions:
|
||||
|
||||
* Affects the most recent stable release of NetBox, or a current beta release
|
||||
* Affects a NetBox instance installed and configured per the official documentation
|
||||
* Is reproducible following a prescribed set of instructions
|
||||
|
||||
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
||||
|
||||
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||
|
||||
### Bug Bounties
|
||||
|
||||
As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated.
|
||||
@@ -1,3 +1,7 @@
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach
|
||||
bleach
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
Django
|
||||
@@ -30,10 +34,14 @@ django-pglocks
|
||||
# https://github.com/korfuri/django-prometheus
|
||||
django-prometheus
|
||||
|
||||
# Django chaching backend using Redis
|
||||
# Django caching backend using Redis
|
||||
# https://github.com/jazzband/django-redis
|
||||
django-redis
|
||||
|
||||
# Django extensions for Rich (terminal text rendering)
|
||||
# https://github.com/adamchainz/django-rich
|
||||
django-rich
|
||||
|
||||
# Django integration for RQ (Reqis queuing)
|
||||
# https://github.com/rq/django-rq
|
||||
django-rq
|
||||
@@ -102,6 +110,10 @@ psycopg2-binary
|
||||
# https://github.com/yaml/pyyaml
|
||||
PyYAML
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python
|
||||
sentry-sdk
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
social-auth-core
|
||||
|
||||
@@ -34,4 +34,4 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
|
||||
|
||||
NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
|
||||
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
|
||||
|
||||
46
docs/administration/error-reporting.md
Normal file
46
docs/administration/error-reporting.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Error Reporting
|
||||
|
||||
## Sentry
|
||||
|
||||
### Enabling Error Reporting
|
||||
|
||||
NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
```
|
||||
|
||||
### Using a Custom DSN
|
||||
|
||||
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
|
||||
|
||||
```
|
||||
https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
```
|
||||
|
||||
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
```
|
||||
|
||||
### Assigning Tags
|
||||
|
||||
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
|
||||
|
||||
```python
|
||||
SENTRY_TAGS = {
|
||||
"custom.foo": "123",
|
||||
"custom.bar": "abc",
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "Reserved tag prefixes"
|
||||
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
|
||||
|
||||
### Testing
|
||||
|
||||
Once the configuration has been saved, restart the NetBox service.
|
||||
|
||||
To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry.
|
||||
@@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces
|
||||
|
||||
{!models/users/objectpermission.md!}
|
||||
|
||||
### Example Constraint Definitions
|
||||
#### Example Constraint Definitions
|
||||
|
||||
| Constraints | Description |
|
||||
| ----------- | ----------- |
|
||||
|
||||
@@ -43,18 +43,6 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## JOBRESULT_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
||||
job results in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||
|
||||
---
|
||||
|
||||
## CUSTOM_VALIDATORS
|
||||
|
||||
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
|
||||
@@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API.
|
||||
|
||||
---
|
||||
|
||||
## JOBRESULT_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
||||
job results in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE_MODE
|
||||
|
||||
Default: False
|
||||
@@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_AMPERAGE
|
||||
|
||||
Default: 15
|
||||
|
||||
The default value for the `amperage` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_MAX_UTILIZATION
|
||||
|
||||
Default: 80
|
||||
|
||||
The default value (percentage) for the `max_utilization` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_VOLTAGE
|
||||
|
||||
Default: 120
|
||||
|
||||
The default value for the `voltage` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## PREFER_IPV4
|
||||
|
||||
Default: False
|
||||
|
||||
54
docs/configuration/error-reporting.md
Normal file
54
docs/configuration/error-reporting.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Error Reporting Settings
|
||||
|
||||
## SENTRY_DSN
|
||||
|
||||
Default: None
|
||||
|
||||
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
|
||||
|
||||
```
|
||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_ENABLED
|
||||
|
||||
Default: False
|
||||
|
||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_SAMPLE_RATE
|
||||
|
||||
Default: 1.0 (all)
|
||||
|
||||
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_TAGS
|
||||
|
||||
An optional dictionary of tag names and values to apply to Sentry error reports.For example:
|
||||
|
||||
```
|
||||
SENTRY_TAGS = {
|
||||
"custom.foo": "123",
|
||||
"custom.bar": "abc",
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "Reserved tag prefixes"
|
||||
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_TRACES_SAMPLE_RATE
|
||||
|
||||
Default: 0 (disabled)
|
||||
|
||||
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
|
||||
|
||||
!!! warning "Consider performance implications"
|
||||
A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).
|
||||
@@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
---
|
||||
|
||||
## CSRF_COOKIE_NAME
|
||||
|
||||
Default: `csrftoken`
|
||||
|
||||
The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
|
||||
|
||||
---
|
||||
|
||||
## CSRF_TRUSTED_ORIGINS
|
||||
|
||||
Default: `[]`
|
||||
@@ -204,6 +212,7 @@ The following model fields support configurable choices:
|
||||
|
||||
* `circuits.Circuit.status`
|
||||
* `dcim.Device.status`
|
||||
* `dcim.Location.status`
|
||||
* `dcim.PowerFeed.status`
|
||||
* `dcim.Rack.status`
|
||||
* `dcim.Site.status`
|
||||
@@ -212,6 +221,7 @@ The following model fields support configurable choices:
|
||||
* `ipam.IPRange.status`
|
||||
* `ipam.Prefix.status`
|
||||
* `ipam.VLAN.status`
|
||||
* `virtualization.Cluster.status`
|
||||
* `virtualization.VirtualMachine.status`
|
||||
|
||||
The following colors are supported:
|
||||
@@ -247,6 +257,23 @@ HTTP_PROXIES = {
|
||||
|
||||
---
|
||||
|
||||
## JINJA2_FILTERS
|
||||
|
||||
Default: `{}`
|
||||
|
||||
A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
|
||||
|
||||
```python
|
||||
def uppercase(x):
|
||||
return str(x).upper()
|
||||
|
||||
JINJA2_FILTERS = {
|
||||
'uppercase': uppercase,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## INTERNAL_IPS
|
||||
|
||||
Default: `('127.0.0.1', '::1')`
|
||||
|
||||
@@ -26,3 +26,8 @@
|
||||
---
|
||||
|
||||
{!models/ipam/asn.md!}
|
||||
|
||||
---
|
||||
|
||||
{!models/ipam/l2vpn.md!}
|
||||
{!models/ipam/l2vpntermination.md!}
|
||||
|
||||
@@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
||||
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
|
||||
* [ipam.IPAddress](../models/ipam/ipaddress.md)
|
||||
* [ipam.IPRange](../models/ipam/iprange.md)
|
||||
* [ipam.L2VPN](../models/ipam/l2vpn.md)
|
||||
* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md)
|
||||
* [ipam.Prefix](../models/ipam/prefix.md)
|
||||
* [ipam.RouteTarget](../models/ipam/routetarget.md)
|
||||
* [ipam.Service](../models/ipam/service.md)
|
||||
|
||||
@@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses:
|
||||
* Deprovisioning
|
||||
* Decommissioned
|
||||
|
||||
Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
|
||||
Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants.
|
||||
|
||||
!!! note
|
||||
NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.
|
||||
|
||||
@@ -11,6 +11,13 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
|
||||
|
||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||
|
||||
### Power over Ethernet (PoE)
|
||||
|
||||
!!! note
|
||||
This feature was added in NetBox v3.3.
|
||||
|
||||
Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs).
|
||||
|
||||
### Wireless Interfaces
|
||||
|
||||
Wireless interfaces may additionally track the following attributes:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
## Interface Templates
|
||||
|
||||
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only."
|
||||
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." Power over Ethernet (PoE) mode and type may also be assigned to interface templates.
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
|
||||
|
||||
Each location must have a name that is unique within its parent site and location, if any.
|
||||
|
||||
Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.)
|
||||
|
||||
@@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o
|
||||
* Region
|
||||
* Site group
|
||||
* Site
|
||||
* Location (devices only)
|
||||
* Device type (devices only)
|
||||
* Role
|
||||
* Platform
|
||||
* Cluster type (VMs only)
|
||||
* Cluster group (VMs only)
|
||||
* Cluster (VMs only)
|
||||
* Tenant group
|
||||
|
||||
@@ -10,7 +10,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea
|
||||
|
||||
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
|
||||
|
||||
* Text: Free-form text (up to 255 characters)
|
||||
* Text: Free-form text (intended for single-line use)
|
||||
* Long text: Free-form of any length; supports Markdown rendering
|
||||
* Integer: A whole number (positive or negative)
|
||||
* Boolean: True or false
|
||||
@@ -26,11 +26,35 @@ Each custom field must have a name. This should be a simple database-friendly st
|
||||
|
||||
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
|
||||
|
||||
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
|
||||
|
||||
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
|
||||
|
||||
### Custom Field Validation
|
||||
### Filtering
|
||||
|
||||
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
|
||||
|
||||
### Grouping
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.)
|
||||
|
||||
This parameter has no effect on the API representation of custom field data.
|
||||
|
||||
### Visibility
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI.
|
||||
|
||||
* **Read/write** (default): The custom field is included when viewing and editing objects.
|
||||
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
|
||||
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
|
||||
|
||||
Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API.
|
||||
|
||||
### Validation
|
||||
|
||||
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ The following data is available as context for Jinja2 templates:
|
||||
* `username` - The name of the user account associated with the change.
|
||||
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
|
||||
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
|
||||
### Default Request Body
|
||||
|
||||
|
||||
21
docs/models/ipam/l2vpn.md
Normal file
21
docs/models/ipam/l2vpn.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# L2VPN
|
||||
|
||||
A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example).
|
||||
|
||||
Each L2VPN instance must have one of the following type associated with it:
|
||||
|
||||
* VPLS
|
||||
* VPWS
|
||||
* EPL
|
||||
* EVPL
|
||||
* EP-LAN
|
||||
* EVP-LAN
|
||||
* EP-TREE
|
||||
* EVP-TREE
|
||||
* VXLAN
|
||||
* VXLAN EVPN
|
||||
* MPLS-EVPN
|
||||
* PBB-EVPN
|
||||
|
||||
!!! note
|
||||
Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add two terminations to a given L2VPN.
|
||||
15
docs/models/ipam/l2vpntermination.md
Normal file
15
docs/models/ipam/l2vpntermination.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# L2VPN Termination
|
||||
|
||||
A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPNs may only have 2 termination points (point-to-point) while others may have many terminations (multipoint).
|
||||
|
||||
Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN.
|
||||
|
||||
The following types of L2VPNs are considered point-to-point:
|
||||
|
||||
* VPWS
|
||||
* EPL
|
||||
* EP-LAN
|
||||
* EP-TREE
|
||||
|
||||
!!! note
|
||||
Choosing any of the above types will result in only being able to add 2 terminations to a given L2VPN.
|
||||
@@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj
|
||||
```
|
||||
|
||||
Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
|
||||
|
||||
### User Token
|
||||
|
||||
!!! info "This feature was introduced in NetBox v3.3"
|
||||
|
||||
When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as:
|
||||
|
||||
```json
|
||||
{
|
||||
"created_by": "$user"
|
||||
}
|
||||
```
|
||||
|
||||
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
|
||||
|
||||
@@ -10,3 +10,10 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
|
||||
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||
|
||||
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
|
||||
|
||||
### Client IP Restriction
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Clusters
|
||||
|
||||
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
|
||||
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
|
||||
|
||||
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Virtual Machines
|
||||
|
||||
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
|
||||
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
|
||||
|
||||
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Wireless LANs
|
||||
|
||||
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
|
||||
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups, and each may be associated with a particular tenant.
|
||||
|
||||
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Wireless Links
|
||||
|
||||
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
|
||||
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. Each wireless link may also be assigned to a particular tenant.
|
||||
|
||||
Each wireless link may have authentication attributes associated with it, including:
|
||||
|
||||
|
||||
28
docs/plugins/development/exceptions.md
Normal file
28
docs/plugins/development/exceptions.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Exceptions
|
||||
|
||||
The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios.
|
||||
|
||||
## `AbortRequest`
|
||||
|
||||
NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer.
|
||||
|
||||
For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model:
|
||||
|
||||
```python
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from dcim.models import Site
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
PROHIBITED_NAMES = ('foo', 'bar', 'baz')
|
||||
|
||||
@receiver(pre_save, sender=Site)
|
||||
def test_abort_request(instance, **kwargs):
|
||||
if instance.name.lower() in PROHIBITED_NAMES:
|
||||
raise AbortRequest(f"Site name can't be {instance.name}!")
|
||||
```
|
||||
|
||||
An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy.
|
||||
|
||||
!!! tip "Consider custom validation rules"
|
||||
This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead.
|
||||
@@ -37,7 +37,7 @@ This class performs two crucial functions:
|
||||
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
|
||||
2. Register the model with NetBox as utilizing these features
|
||||
|
||||
Simply subclass BaseModel when defining a model in your plugin:
|
||||
Simply subclass NetBoxModel when defining a model in your plugin:
|
||||
|
||||
```python
|
||||
# models.py
|
||||
@@ -49,6 +49,24 @@ class MyModel(NetBoxModel):
|
||||
...
|
||||
```
|
||||
|
||||
### The `clone()` Method
|
||||
|
||||
!!! info
|
||||
This method was introduced in NetBox v3.3.
|
||||
|
||||
The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined.
|
||||
|
||||
Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content:
|
||||
|
||||
```python
|
||||
class MyModel(NetBoxModel):
|
||||
|
||||
def clone(self):
|
||||
attrs = super().clone()
|
||||
attrs['extra-value'] = 123
|
||||
return attrs
|
||||
```
|
||||
|
||||
### Enabling Features Individually
|
||||
|
||||
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
|
||||
|
||||
@@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
selection:
|
||||
members: false
|
||||
members:
|
||||
- __init__
|
||||
|
||||
@@ -215,6 +215,8 @@ The following custom template tags are available in NetBox.
|
||||
|
||||
::: utilities.templatetags.builtins.tags.checkmark
|
||||
|
||||
::: utilities.templatetags.builtins.tags.customfield_value
|
||||
|
||||
::: utilities.templatetags.builtins.tags.tag
|
||||
|
||||
## Filters
|
||||
|
||||
@@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem
|
||||
|
||||
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
||||
|
||||
| View Class | Description |
|
||||
|--------------------|--------------------------------|
|
||||
| `ObjectView` | View a single object |
|
||||
| `ObjectEditView` | Create or edit a single object |
|
||||
| `ObjectDeleteView` | Delete a single object |
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
| View Class | Description |
|
||||
|----------------------|--------------------------------------------------------|
|
||||
| `ObjectView` | View a single object |
|
||||
| `ObjectEditView` | Create or edit a single object |
|
||||
| `ObjectDeleteView` | Delete a single object |
|
||||
| `ObjectChildrenView` | A list of child objects within the context of a parent |
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
||||
@@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR
|
||||
members:
|
||||
- get_object
|
||||
|
||||
::: netbox.views.generic.ObjectChildrenView
|
||||
selection:
|
||||
members:
|
||||
- get_children
|
||||
- prep_table_data
|
||||
|
||||
## Multi-Object Views
|
||||
|
||||
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
|
||||
|
||||
353
docs/reference/markdown.md
Normal file
353
docs/reference/markdown.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
# Markdown
|
||||
|
||||
NetBox supports markdown rendering for certain text fields.
|
||||
|
||||
## Syntax
|
||||
|
||||
##### Table of Contents
|
||||
[Headers](#headers)
|
||||
[Emphasis](#emphasis)
|
||||
[Lists](#lists)
|
||||
[Links](#links)
|
||||
[Images](#images)
|
||||
[Code Blocks](#code)
|
||||
[Tables](#tables)
|
||||
[Blockquotes](#blockquotes)
|
||||
[Inline HTML](#html)
|
||||
[Horizontal Rule](#hr)
|
||||
[Line Breaks](#lines)
|
||||
|
||||
<a name="headers"></a>
|
||||
|
||||
## Headers
|
||||
|
||||
```no-highlight
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
Alternatively, for H1 and H2, an underline-ish style:
|
||||
|
||||
Alt-H1
|
||||
======
|
||||
|
||||
Alt-H2
|
||||
------
|
||||
```
|
||||
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
<a name="emphasis"></a>
|
||||
|
||||
## Emphasis
|
||||
|
||||
```no-highlight
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
```
|
||||
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
|
||||
|
||||
<a name="lists"></a>
|
||||
|
||||
## Lists
|
||||
|
||||
(In this example, leading and trailing spaces are shown with with dots: ⋅)
|
||||
|
||||
```no-highlight
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
⋅⋅* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
⋅⋅1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
|
||||
⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
|
||||
⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
```
|
||||
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
To have a line break without a paragraph, you will need to use two trailing spaces.
|
||||
Note that this line is separate, but within the same paragraph.
|
||||
(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
|
||||
<a name="links"></a>
|
||||
|
||||
## Links
|
||||
|
||||
There are two ways to create links.
|
||||
|
||||
```no-highlight
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
```
|
||||
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
|
||||
<a name="images"></a>
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
```
|
||||
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
|
||||
<a name="code"></a>
|
||||
|
||||
## Code blocks
|
||||
|
||||
```
|
||||
Inline `code` has `back-ticks around` it.
|
||||
```
|
||||
|
||||
Inline `code` has `back-ticks around` it.
|
||||
|
||||
Blocks of code are fenced by lines with three back-ticks <code>```</code>
|
||||
|
||||
````
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
````
|
||||
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
|
||||
<a name="tables"></a>
|
||||
|
||||
## Tables
|
||||
|
||||
```no-highlight
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell.
|
||||
The outer pipes (|) are optional, and you don't need to make the
|
||||
raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
```
|
||||
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
|
||||
<a name="blockquotes"></a>
|
||||
|
||||
## Blockquotes
|
||||
|
||||
```no-highlight
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
```
|
||||
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
|
||||
<a name="html"></a>
|
||||
|
||||
## Inline HTML
|
||||
|
||||
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
|
||||
|
||||
```no-highlight
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
|
||||
<a name="hr"></a>
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
```
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
```
|
||||
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
|
||||
<a name="lines"></a>
|
||||
|
||||
## Line Breaks
|
||||
|
||||
|
||||
```
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
```
|
||||
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also begins a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
|
||||
Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)
|
||||
@@ -10,6 +10,17 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 3.3](./version-3.3.md) (August 2022)
|
||||
|
||||
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
|
||||
* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
|
||||
* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
|
||||
* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
|
||||
* Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
|
||||
* Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
|
||||
* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
|
||||
* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
|
||||
|
||||
#### [Version 3.2](./version-3.2.md) (April 2022)
|
||||
|
||||
* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))
|
||||
|
||||
@@ -1,5 +1,158 @@
|
||||
# NetBox v3.2
|
||||
|
||||
## v3.2.8 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
|
||||
* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
|
||||
* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
|
||||
* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
|
||||
* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
|
||||
* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
|
||||
* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
|
||||
* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
|
||||
* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
|
||||
* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
|
||||
|
||||
---
|
||||
|
||||
## v3.2.7 (2022-07-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9705](https://github.com/netbox-community/netbox/issues/9705) - Support filter expressions for the `serial` field on racks, devices, and inventory items
|
||||
* [#9741](https://github.com/netbox-community/netbox/issues/9741) - Check for UserConfig instance during user login
|
||||
* [#9745](https://github.com/netbox-community/netbox/issues/9745) - Add wireless LANs and links to global search
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9437](https://github.com/netbox-community/netbox/issues/9437) - Standardize form submission buttons and behavior when using enter key
|
||||
* [#9499](https://github.com/netbox-community/netbox/issues/9499) - Fix filtered bulk deletion of VM Interfaces
|
||||
* [#9634](https://github.com/netbox-community/netbox/issues/9634) - Fix image URLs in rack elevations when using external storage
|
||||
* [#9715](https://github.com/netbox-community/netbox/issues/9715) - Fix `SOCIAL_AUTH_PIPELINE` config parameter not taking effect
|
||||
* [#9754](https://github.com/netbox-community/netbox/issues/9754) - Fix regression introduced by #9632
|
||||
* [#9746](https://github.com/netbox-community/netbox/issues/9746) - Permit filtering interfaces by arbitrary speed value in UI
|
||||
* [#9749](https://github.com/netbox-community/netbox/issues/9749) - Retain original slug values when modifying object names
|
||||
* [#9775](https://github.com/netbox-community/netbox/issues/9775) - Fix exception when viewing a report with no description
|
||||
|
||||
---
|
||||
|
||||
## v3.2.6 (2022-07-11)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes
|
||||
* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID
|
||||
* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device
|
||||
* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list
|
||||
* [#9686](https://github.com/netbox-community/netbox/issues/9686) - Add tenant group column for all object tables with tenant assignments
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends
|
||||
* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned
|
||||
* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer
|
||||
* [#9632](https://github.com/netbox-community/netbox/issues/9632) - Automatically focus on search box when expanding dropdowns
|
||||
* [#9657](https://github.com/netbox-community/netbox/issues/9657) - Fix filtering for custom fields and webhooks in the UI
|
||||
* [#9682](https://github.com/netbox-community/netbox/issues/9682) - Fix bulk assignment of ASNs to sites
|
||||
* [#9687](https://github.com/netbox-community/netbox/issues/9687) - Don't restrict custom text field lengths when entering via UI form
|
||||
* [#9704](https://github.com/netbox-community/netbox/issues/9704) - Include `last_updated` field on JournalEntry REST API serializer
|
||||
|
||||
---
|
||||
|
||||
## v3.2.5 (2022-06-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list
|
||||
* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes
|
||||
* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view
|
||||
* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module
|
||||
* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters
|
||||
* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view
|
||||
* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables
|
||||
* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation
|
||||
* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms
|
||||
* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons
|
||||
* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links
|
||||
* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data
|
||||
* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure
|
||||
* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers
|
||||
* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
|
||||
* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
|
||||
* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
|
||||
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs
|
||||
* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
|
||||
* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
|
||||
* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI
|
||||
* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view
|
||||
|
||||
---
|
||||
|
||||
## v3.2.4 (2022-05-31)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
|
||||
* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
|
||||
* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
|
||||
* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
|
||||
* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
|
||||
* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
|
||||
* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
|
||||
* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
|
||||
* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
|
||||
* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
|
||||
* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
|
||||
* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
|
||||
* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
|
||||
* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
|
||||
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
|
||||
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
|
||||
|
||||
---
|
||||
|
||||
## v3.2.3 (2022-05-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication
|
||||
* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users
|
||||
* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group
|
||||
* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade
|
||||
* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown
|
||||
* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views
|
||||
* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list
|
||||
* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module
|
||||
* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments
|
||||
* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry
|
||||
* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device
|
||||
* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices
|
||||
* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization
|
||||
* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface
|
||||
* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API
|
||||
* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships
|
||||
* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates
|
||||
|
||||
---
|
||||
|
||||
## v3.2.2 (2022-04-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
236
docs/release-notes/version-3.3.md
Normal file
236
docs/release-notes/version-3.3.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3-beta2 (2022-08-03)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
|
||||
* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
|
||||
* Several fields on the cable API serializers have been altered or removed to support multiple-object cable terminations:
|
||||
|
||||
| Old Name | Old Type | New Name | New Type |
|
||||
|----------------------|----------|-----------------------|----------|
|
||||
| `termination_a_type` | string | _Removed_ | - |
|
||||
| `termination_b_type` | string | _Removed_ | - |
|
||||
| `termination_a_id` | integer | _Removed_ | - |
|
||||
| `termination_b_id` | integer | _Removed_ | - |
|
||||
| `termination_a` | object | `a_terminations` | list |
|
||||
| `termination_b` | object | `b_terminations` | list |
|
||||
|
||||
* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed:
|
||||
|
||||
| Old Name | Old Type | New Name | New Type |
|
||||
|--------------------------------|----------|---------------------------------|----------|
|
||||
| `link_peer` | object | `link_peers` | list |
|
||||
| `link_peer_type` | string | `link_peers_type` | string |
|
||||
| `connected_endpoint` | object | `connected_endpoints` | list |
|
||||
| `connected_endpoint_type` | string | `connected_endpoints_type` | string |
|
||||
| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean |
|
||||
|
||||
* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
|
||||
|
||||
When creating a cable in NetBox, each end can now be attached to multiple objects. This allows accurate modeling of duplex fiber connections to individual termination ports and breakout cables, as examples. (Note that all terminations attached to one end of a cable must be the same object type, but do not need to connect to the same parent object.) Additionally, cable terminations can now be modified without needing to delete and recreate the cable.
|
||||
|
||||
#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
|
||||
|
||||
NetBox can now model a variety of L2 VPN technologies, including VXLAN, VPLS, and others. Each L2VPN can be terminated to multiple device or virtual machine interfaces and/or VLANs to track connectivity across an overlay. Similarly to VRFs, each L2VPN can also have import and export route targets associated with it.
|
||||
|
||||
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
|
||||
|
||||
Two new fields have been added to the device interface model to track power over Ethernet (PoE) capabilities:
|
||||
|
||||
* **PoE mode**: Power supplying equipment (PSE) or powered device (PD)
|
||||
* **PoE type**: Applicable IEEE standard or other power type
|
||||
|
||||
#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
|
||||
|
||||
Device type height can now be specified in 0.5U increments, allowing for the creation of half-height devices. Additionally, a device can be installed at the half-unit mark within a rack (e.g. U2.5). For example, two half-height devices positioned in sequence will consume a single rack unit; two consecutive 1.5U devices will consume 3U of space.
|
||||
|
||||
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
|
||||
|
||||
API tokens can now be restricted to use by certain client IP addresses or networks. For example, an API token with its `allowed_ips` list set to `[192.0.2.0/24]` will only permit authentication from API clients within that network; requests from other sources will fail authentication. This can be very useful for restricting the use of a token to specific clients.
|
||||
|
||||
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
|
||||
|
||||
NetBox's permission constraints have been expanded to support referencing the current user associated with a request using the special `$user` token. As an example, this enables an administrator to efficiently grant each user to edit his or her own journal entries, but not those created by other users.
|
||||
|
||||
```json
|
||||
{
|
||||
"created_by": "$user"
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
|
||||
|
||||
A `group_name` field has been added to the custom field model to enable organizing related custom fields by group. Similarly to custom links, custom links which have been assigned to a common group will be rendered within that group when viewing an object in the UI. (Custom field grouping has no effect on API operation.)
|
||||
|
||||
#### Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
|
||||
|
||||
The behavior of each custom field within the NetBox UI can now be controlled individually by toggling its UI visibility. Three settings are available:
|
||||
|
||||
* **Read/write**: The custom field is included when viewing and editing objects (default).
|
||||
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
|
||||
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
|
||||
|
||||
Custom field UI visibility has no impact on API operation.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
||||
* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations
|
||||
* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations
|
||||
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
|
||||
* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
|
||||
* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
|
||||
* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP
|
||||
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
|
||||
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
|
||||
* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations
|
||||
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
|
||||
* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
|
||||
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
|
||||
* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields
|
||||
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
|
||||
* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
|
||||
|
||||
### Bug Fixes (from Beta1)
|
||||
|
||||
* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device
|
||||
* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data
|
||||
* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form
|
||||
* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables
|
||||
* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view
|
||||
* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination
|
||||
* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects
|
||||
* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks
|
||||
* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination
|
||||
* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination
|
||||
* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects
|
||||
* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647)
|
||||
* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination
|
||||
* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units
|
||||
|
||||
### Plugins API
|
||||
|
||||
* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations
|
||||
* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view
|
||||
* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging
|
||||
* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
|
||||
* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
|
||||
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
|
||||
* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* List results can now be ordered by field, by appending `?ordering={fieldname}` to the query. Multiple fields can be specified by separating the field names with a comma, e.g. `?ordering=site,name`. To invert the ordering, prepend a hyphen to the field name, e.g. `?ordering=-name`.
|
||||
* Added the following endpoints:
|
||||
* `/api/dcim/cable-terminations/`
|
||||
* `/api/ipam/l2vpns/`
|
||||
* `/api/ipam/l2vpn-terminations/`
|
||||
* circuits.Circuit
|
||||
* Added optional `termination_date` field
|
||||
* circuits.CircuitTermination
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* Added `custom_fields` and `tags` fields
|
||||
* dcim.Cable
|
||||
* `termination_a_type` has been renamed to `a_terminations_type`
|
||||
* `termination_b_type` has been renamed to `b_terminations_type`
|
||||
* `termination_a` renamed to `a_terminations` and now returns a list of objects
|
||||
* `termination_b` renamed to `b_terminations` and now returns a list of objects
|
||||
* `termination_a_id` has been removed
|
||||
* `termination_b_id` has been removed
|
||||
* dcim.ConsolePort
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.ConsoleServerPort
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.Device
|
||||
* The `position` field has been changed from an integer to a decimal
|
||||
* dcim.DeviceType
|
||||
* The `u_height` field has been changed from an integer to a decimal
|
||||
* dcim.FrontPort
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.Interface
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* Added the optional `poe_mode` and `poe_type` fields
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
* dcim.InterfaceTemplate
|
||||
* Added the optional `poe_mode` and `poe_type` fields
|
||||
* dcim.Location
|
||||
* Added required `status` field (default value: `active`)
|
||||
* dcim.PowerOutlet
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.PowerFeed
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.PowerPort
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* dcim.Rack
|
||||
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
|
||||
* dcim.RearPort
|
||||
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
|
||||
* `link_peer_type` has been renamed to `link_peers_type`
|
||||
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
|
||||
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
|
||||
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
|
||||
* extras.ConfigContext
|
||||
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
|
||||
* extras.CustomField
|
||||
* Added `group_name` and `ui_visibility` fields
|
||||
* ipam.IPAddress
|
||||
* The `nat_inside` field no longer requires a unique value
|
||||
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
|
||||
* ipam.VLAN
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
* users.Token
|
||||
* Added the `allowed_ips` array field
|
||||
* Added the read-only `last_used` datetime field
|
||||
* virtualization.Cluster
|
||||
* Added required `status` field (default value: `active`)
|
||||
* virtualization.VirtualMachine
|
||||
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
|
||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||
* Added the optional `device` field
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
wireless.WirelessLAN
|
||||
* Added `tenant` field
|
||||
wireless.WirelessLink
|
||||
* Added `tenant` field
|
||||
@@ -29,6 +29,11 @@ $ curl https://netbox/api/dcim/sites/
|
||||
}
|
||||
```
|
||||
|
||||
When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently.
|
||||
|
||||
!!! note
|
||||
The "last used" time for tokens will not be updated while maintenance mode is enabled.
|
||||
|
||||
## Initial Token Provisioning
|
||||
|
||||
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
|
||||
|
||||
@@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
|
||||
```no-highlight
|
||||
GET /api/ipam/vlans/?group_id__n=3203
|
||||
```
|
||||
|
||||
## Ordering Objects
|
||||
|
||||
To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/sites/?ordering=facility
|
||||
```
|
||||
|
||||
To invert the ordering, prepend a hyphen to the field name:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/sites/?ordering=-facility
|
||||
```
|
||||
|
||||
Multiple fields can be specified by separating the field names with a comma. For example:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/sites/?ordering=facility,-name
|
||||
```
|
||||
|
||||
@@ -73,6 +73,7 @@ nav:
|
||||
- Required Settings: 'configuration/required-settings.md'
|
||||
- Optional Settings: 'configuration/optional-settings.md'
|
||||
- Dynamic Settings: 'configuration/dynamic-settings.md'
|
||||
- Error Reporting: 'configuration/error-reporting.md'
|
||||
- Remote Authentication: 'configuration/remote-authentication.md'
|
||||
- Core Functionality:
|
||||
- IP Address Management: 'core-functionality/ipam.md'
|
||||
@@ -117,23 +118,26 @@ nav:
|
||||
- REST API: 'plugins/development/rest-api.md'
|
||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||
- Exceptions: 'plugins/development/exceptions.md'
|
||||
- Administration:
|
||||
- Authentication:
|
||||
- Overview: 'administration/authentication/overview.md'
|
||||
- Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md'
|
||||
- Okta: 'administration/authentication/okta.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
- Error Reporting: 'administration/error-reporting.md'
|
||||
- Housekeeping: 'administration/housekeeping.md'
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
- REST API:
|
||||
- Overview: 'rest-api/overview.md'
|
||||
- Filtering: 'rest-api/filtering.md'
|
||||
- Filtering & Ordering: 'rest-api/filtering.md'
|
||||
- Authentication: 'rest-api/authentication.md'
|
||||
- GraphQL API:
|
||||
- Overview: 'graphql-api/overview.md'
|
||||
- Reference:
|
||||
- Conditions: 'reference/conditions.md'
|
||||
- Markdown: 'reference/markdown.md'
|
||||
- Development:
|
||||
- Introduction: 'development/index.md'
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
@@ -148,6 +152,7 @@ nav:
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Summary: 'release-notes/index.md'
|
||||
- Version 3.3: 'release-notes/version-3.3.md'
|
||||
- Version 3.2: 'release-notes/version-3.2.md'
|
||||
- Version 3.1: 'release-notes/version-3.1.md'
|
||||
- Version 3.0: 'release-notes/version-3.0.md'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.api import WritableNestedSerializer
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCircuitSerializer',
|
||||
|
||||
@@ -2,12 +2,12 @@ from rest_framework import serializers
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
|
||||
from dcim.api.serializers import LinkTerminationSerializer
|
||||
from dcim.api.nested_serializers import NestedSiteSerializer
|
||||
from dcim.api.serializers import CabledObjectSerializer
|
||||
from ipam.models import ASN
|
||||
from ipam.api.nested_serializers import NestedASNSerializer
|
||||
from netbox.api import ChoiceField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
@@ -92,23 +92,22 @@ class CircuitSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
|
||||
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
|
||||
'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
|
||||
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
||||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
|
||||
'_occupied', 'created', 'last_updated',
|
||||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import NetBoxRouter
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet):
|
||||
|
||||
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = CircuitTermination.objects.prefetch_related(
|
||||
'circuit', 'site', 'provider_network', 'cable'
|
||||
'circuit', 'site', 'provider_network', 'cable__terminations'
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filtersets.CircuitTerminationFilterSet
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.filtersets import CableTerminationFilterSet
|
||||
from dcim.filtersets import CabledObjectFilterSet
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.models import ASN
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
@@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
|
||||
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
StaticSelect,
|
||||
)
|
||||
|
||||
@@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker()
|
||||
)
|
||||
termination_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker()
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
label='Commit rate (Kbps)'
|
||||
@@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Circuit
|
||||
fieldsets = (
|
||||
(None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
|
||||
('Circuit', ('provider', 'type', 'status', 'description')),
|
||||
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||
('Tenancy', ('tenant',)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'tenant', 'commit_rate', 'description', 'comments',
|
||||
|
||||
@@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
|
||||
'description', 'comments',
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
||||
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilterForm',
|
||||
@@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('ASN', ('asn',)),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -84,10 +84,10 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Provider', ('provider_id', 'provider_network_id')),
|
||||
('Attributes', ('type_id', 'status', 'commit_rate')),
|
||||
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
@@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker
|
||||
)
|
||||
termination_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=0,
|
||||
|
||||
@@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
|
||||
('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
|
||||
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'comments', 'tags',
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
'tenant_group', 'tenant', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
@@ -110,11 +111,12 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
widgets = {
|
||||
'status': StaticSelect(),
|
||||
'install_date': DatePicker(),
|
||||
'termination_date': DatePicker(),
|
||||
'commit_rate': SelectSpeedWidget(),
|
||||
}
|
||||
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
class CircuitTerminationForm(NetBoxModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
@@ -159,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from circuits import filtersets, models
|
||||
from dcim.graphql.mixins import CabledObjectMixin
|
||||
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
|
||||
__all__ = (
|
||||
@@ -10,7 +12,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CircuitTerminationType(ObjectType):
|
||||
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CircuitTermination
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0035_provider_asns'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='termination_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
16
netbox/circuits/migrations/0037_new_cabling_models.py
Normal file
16
netbox/circuits/migrations/0037_new_cabling_models.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
]
|
||||
20
netbox/circuits/migrations/0038_cabling_cleanup.py
Normal file
20
netbox/circuits/migrations/0038_cabling_cleanup.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0037_new_cabling_models'),
|
||||
('dcim', '0160_populate_cable_ends'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
]
|
||||
@@ -4,8 +4,10 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
from dcim.models import LinkTermination
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
|
||||
from dcim.models import CabledObjectModel
|
||||
from netbox.models import (
|
||||
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
|
||||
)
|
||||
from netbox.models.features import WebhooksMixin
|
||||
|
||||
__all__ = (
|
||||
@@ -78,7 +80,12 @@ class Circuit(NetBoxModel):
|
||||
install_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Date installed'
|
||||
verbose_name='Installed'
|
||||
)
|
||||
termination_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Terminates'
|
||||
)
|
||||
commit_rate = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
@@ -119,7 +126,7 @@ class Circuit(NetBoxModel):
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -136,7 +143,14 @@ class Circuit(NetBoxModel):
|
||||
return CircuitStatusChoices.colors.get(self.status)
|
||||
|
||||
|
||||
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
|
||||
class CircuitTermination(
|
||||
CustomFieldsMixin,
|
||||
CustomLinksMixin,
|
||||
TagsMixin,
|
||||
WebhooksMixin,
|
||||
ChangeLoggedModel,
|
||||
CabledObjectModel
|
||||
):
|
||||
circuit = models.ForeignKey(
|
||||
to='circuits.Circuit',
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
|
||||
if not raw:
|
||||
peer_termination = instance.get_peer_termination()
|
||||
if peer_termination:
|
||||
rebuild_paths(peer_termination)
|
||||
rebuild_paths([peer_termination])
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from .columns import CommitRateColumn
|
||||
|
||||
__all__ = (
|
||||
@@ -39,7 +39,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
class CircuitTable(NetBoxTable):
|
||||
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Circuit ID'
|
||||
@@ -48,7 +48,6 @@ class CircuitTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
termination_a = tables.TemplateColumn(
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side A'
|
||||
@@ -59,7 +58,7 @@ class CircuitTable(NetBoxTable):
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
@@ -69,8 +68,9 @@ class CircuitTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z',
|
||||
'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||
|
||||
@@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asns = tables.ManyToManyColumn(
|
||||
asns = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='ASNs'
|
||||
)
|
||||
@@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable):
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
@@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'install_date': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_termination_date(self):
|
||||
params = {'termination_date': ['2021-01-01', '2021-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_commit_rate(self):
|
||||
params = {'commit_rate': ['1000', '2000']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -356,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
|
||||
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
|
||||
|
||||
def test_term_side(self):
|
||||
params = {'term_side': 'A'}
|
||||
|
||||
@@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
|
||||
'tenant': None,
|
||||
'install_date': datetime.date(2020, 1, 1),
|
||||
'termination_date': datetime.date(2021, 1, 1),
|
||||
'commit_rate': 1000,
|
||||
'description': 'A new circuit',
|
||||
'comments': 'Some comments',
|
||||
@@ -245,7 +246,7 @@ class CircuitTerminationTestCase(
|
||||
device=device,
|
||||
name='Interface 1'
|
||||
)
|
||||
Cable(termination_a=circuittermination, termination_b=interface).save()
|
||||
Cable(a_terminations=[circuittermination], b_terminations=[interface]).save()
|
||||
|
||||
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from dcim.views import CableCreateView, PathTraceView
|
||||
from dcim.views import PathTraceView
|
||||
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import *
|
||||
@@ -60,7 +60,6 @@ urlpatterns = [
|
||||
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
|
||||
|
||||
]
|
||||
|
||||
@@ -30,9 +30,10 @@ class ProviderView(generic.ObjectView):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=instance
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -91,9 +92,10 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
Q(termination_a__provider_network=instance.pk) |
|
||||
Q(termination_z__provider_network=instance.pk)
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user)
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -147,7 +149,7 @@ class CircuitTypeView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',))
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class CircuitListView(generic.ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'termination_a', 'termination_z'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
filterset_form = forms.CircuitFilterForm
|
||||
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
|
||||
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
|
||||
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
|
||||
@@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'ComponentNestedModuleSerializer',
|
||||
'ModuleBayNestedModuleSerializer',
|
||||
'NestedCableSerializer',
|
||||
'NestedConsolePortSerializer',
|
||||
'NestedConsolePortTemplateSerializer',
|
||||
@@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
|
||||
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.Module
|
||||
fields = ['id', 'url', 'display', 'serial']
|
||||
|
||||
|
||||
class ComponentNestedModuleSerializer(WritableNestedSerializer):
|
||||
"""
|
||||
Used by device component serializers.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
@@ -7,14 +9,17 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.api.nested_serializers import (
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
|
||||
NestedVRFSerializer,
|
||||
)
|
||||
from ipam.models import ASN, VLAN
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import (
|
||||
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
)
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -25,58 +30,68 @@ from wireless.models import WirelessLAN
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
class LinkTerminationSerializer(serializers.ModelSerializer):
|
||||
link_peer_type = serializers.SerializerMethodField(read_only=True)
|
||||
link_peer = serializers.SerializerMethodField(read_only=True)
|
||||
class CabledObjectSerializer(serializers.ModelSerializer):
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
cable_end = serializers.CharField(read_only=True)
|
||||
link_peers_type = serializers.SerializerMethodField(read_only=True)
|
||||
link_peers = serializers.SerializerMethodField(read_only=True)
|
||||
_occupied = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_link_peer_type(self, obj):
|
||||
if obj._link_peer is not None:
|
||||
return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
|
||||
def get_link_peers_type(self, obj):
|
||||
"""
|
||||
Return the type of the peer link terminations, or None.
|
||||
"""
|
||||
if not obj.cable:
|
||||
return None
|
||||
|
||||
if obj.link_peers:
|
||||
return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
|
||||
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_link_peer(self, obj):
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_link_peers(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the link termination model.
|
||||
"""
|
||||
if obj._link_peer is not None:
|
||||
serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._link_peer, context=context).data
|
||||
return None
|
||||
if not obj.link_peers:
|
||||
return []
|
||||
|
||||
# Return serialized peer termination objects
|
||||
serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.link_peers, context=context, many=True).data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
|
||||
def get__occupied(self, obj):
|
||||
return obj._occupied
|
||||
|
||||
|
||||
class ConnectedEndpointSerializer(serializers.ModelSerializer):
|
||||
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoint = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Legacy serializer for pre-v3.3 connections
|
||||
"""
|
||||
connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoints = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_connected_endpoint_type(self, obj):
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
|
||||
return None
|
||||
def get_connected_endpoints_type(self, obj):
|
||||
if endpoints := obj.connected_endpoints:
|
||||
return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_connected_endpoint(self, obj):
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_connected_endpoints(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the type of connected object.
|
||||
"""
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
|
||||
if endpoints := obj.connected_endpoints:
|
||||
serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._path.destination, context=context).data
|
||||
return None
|
||||
return serializer(endpoints, many=True, context=context).data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
|
||||
def get_connected_endpoint_reachable(self, obj):
|
||||
if obj._path is not None:
|
||||
return obj._path.is_active
|
||||
return None
|
||||
def get_connected_endpoints_reachable(self, obj):
|
||||
return obj._path and obj._path.is_complete and obj._path.is_active
|
||||
|
||||
|
||||
#
|
||||
@@ -149,6 +164,7 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
|
||||
site = NestedSiteSerializer()
|
||||
parent = NestedLocationSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -156,8 +172,8 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
]
|
||||
|
||||
|
||||
@@ -201,7 +217,11 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
"""
|
||||
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
|
||||
"""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
id = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
read_only=True
|
||||
)
|
||||
name = serializers.CharField(read_only=True)
|
||||
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
@@ -246,7 +266,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
|
||||
)
|
||||
legend_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
|
||||
)
|
||||
margin_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
|
||||
)
|
||||
exclude = serializers.IntegerField(
|
||||
required=False,
|
||||
@@ -283,6 +306,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
u_height = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
label='Position (U)',
|
||||
min_value=decimal.Decimal(0.5),
|
||||
default=1.0
|
||||
)
|
||||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -315,7 +345,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||
|
||||
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -325,13 +364,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -341,13 +390,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -357,14 +416,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
||||
'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -383,48 +451,85 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
poe_mode = ChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
allow_blank=True
|
||||
)
|
||||
poe_type = ChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
allow_blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
|
||||
'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'poe_mode', 'poe_type', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
|
||||
'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = NestedRearPortTemplateSerializer()
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||
'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -478,7 +583,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
def get_component(self, obj):
|
||||
if obj.component is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.component, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.component, context=context).data
|
||||
|
||||
@@ -524,7 +629,14 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
|
||||
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
|
||||
position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
|
||||
position = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
allow_null=True,
|
||||
label='Position (U)',
|
||||
min_value=decimal.Decimal(0.5),
|
||||
default=None
|
||||
)
|
||||
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True)
|
||||
@@ -594,7 +706,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -611,18 +723,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
|
||||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
|
||||
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -639,18 +751,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
|
||||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
|
||||
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -671,21 +783,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
|
||||
allow_blank=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
|
||||
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -697,19 +806,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
allow_blank=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
||||
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -724,6 +832,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
|
||||
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -732,7 +842,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
many=True
|
||||
)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
|
||||
wireless_link = NestedWirelessLinkSerializer(read_only=True)
|
||||
wireless_lans = SerializedPKRelatedField(
|
||||
queryset=WirelessLAN.objects.all(),
|
||||
@@ -748,10 +858,11 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
|
||||
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
|
||||
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
|
||||
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
|
||||
'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -768,7 +879,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -776,13 +887,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
|
||||
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
@@ -798,7 +908,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'display', 'name', 'label']
|
||||
|
||||
|
||||
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -807,26 +917,25 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = FrontPortRearPortSerializer()
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
|
||||
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields',
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -870,7 +979,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
def get_component(self, obj):
|
||||
if obj.component is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.component, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.component, context=context).data
|
||||
|
||||
@@ -897,14 +1006,8 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
|
||||
|
||||
class CableSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
)
|
||||
termination_b_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
)
|
||||
termination_a = serializers.SerializerMethodField(read_only=True)
|
||||
termination_b = serializers.SerializerMethodField(read_only=True)
|
||||
a_terminations = GenericObjectSerializer(many=True, required=False)
|
||||
b_terminations = GenericObjectSerializer(many=True, required=False)
|
||||
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
|
||||
@@ -912,34 +1015,10 @@ class CableSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
|
||||
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def _get_termination(self, obj, side):
|
||||
"""
|
||||
Serialize a nested representation of a termination.
|
||||
"""
|
||||
if side.lower() not in ['a', 'b']:
|
||||
raise ValueError("Termination side must be either A or B.")
|
||||
termination = getattr(obj, 'termination_{}'.format(side.lower()))
|
||||
if termination is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(termination, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
data = serializer(termination, context=context).data
|
||||
|
||||
return data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_termination_a(self, obj):
|
||||
return self._get_termination(obj, 'a')
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_termination_b(self, obj):
|
||||
return self._get_termination(obj, 'b')
|
||||
|
||||
|
||||
class TracedCableSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
@@ -954,46 +1033,40 @@ class TracedCableSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CableTerminationSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
|
||||
termination_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
)
|
||||
termination = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_termination(self, obj):
|
||||
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.termination, context=context).data
|
||||
|
||||
|
||||
class CablePathSerializer(serializers.ModelSerializer):
|
||||
origin_type = ContentTypeField(read_only=True)
|
||||
origin = serializers.SerializerMethodField(read_only=True)
|
||||
destination_type = ContentTypeField(read_only=True)
|
||||
destination = serializers.SerializerMethodField(read_only=True)
|
||||
path = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CablePath
|
||||
fields = [
|
||||
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_origin(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the origin.
|
||||
"""
|
||||
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.origin, context=context).data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_destination(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the destination, if any.
|
||||
"""
|
||||
if obj.destination_id is not None:
|
||||
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.destination, context=context).data
|
||||
return None
|
||||
fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for node in obj.get_path():
|
||||
serializer = get_serializer_for_model(node, prefix='Nested')
|
||||
for nodes in obj.path_objects:
|
||||
serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(node, context=context).data)
|
||||
ret.append(serializer(nodes, context=context, many=True).data)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -1036,7 +1109,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
@@ -1060,13 +1133,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
choices=PowerFeedPhaseChoices,
|
||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
|
||||
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
|
||||
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import NetBoxRouter
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
|
||||
|
||||
# Cables
|
||||
router.register('cables', views.CableViewSet)
|
||||
router.register('cable-terminations', views.CableTerminationViewSet)
|
||||
|
||||
# Virtual chassis
|
||||
router.register('virtual-chassis', views.VirtualChassisViewSet)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -13,14 +12,18 @@ from rest_framework.viewsets import ViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim import filtersets
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.api.views import ConfigContextQuerySetMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
@@ -50,37 +53,30 @@ class PathEndpointMixin(object):
|
||||
# Initialize the path array
|
||||
path = []
|
||||
|
||||
# Render SVG image if requested
|
||||
if request.GET.get('render', None) == 'svg':
|
||||
# Render SVG
|
||||
try:
|
||||
width = min(int(request.GET.get('width')), 1600)
|
||||
width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
|
||||
except (ValueError, TypeError):
|
||||
width = None
|
||||
drawing = obj.get_trace_svg(
|
||||
base_url=request.build_absolute_uri('/'),
|
||||
width=width
|
||||
)
|
||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||
width = CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width)
|
||||
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
|
||||
|
||||
for near_end, cable, far_end in obj.trace():
|
||||
if near_end is None:
|
||||
# Split paths
|
||||
# Serialize path objects, iterating over each three-tuple in the path
|
||||
for near_ends, cable, far_ends in obj.trace():
|
||||
if near_ends:
|
||||
serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
|
||||
else:
|
||||
# Path is split; stop here
|
||||
break
|
||||
if cable:
|
||||
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
|
||||
if far_ends:
|
||||
serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
x = serializer_a(near_end, context={'request': request}).data
|
||||
if cable is not None:
|
||||
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
|
||||
else:
|
||||
y = None
|
||||
if far_end is not None:
|
||||
serializer_b = get_serializer_for_model(far_end, prefix='Nested')
|
||||
z = serializer_b(far_end, context={'request': request}).data
|
||||
else:
|
||||
z = None
|
||||
|
||||
path.append((x, y, z))
|
||||
path.append((near_ends, cable, far_ends))
|
||||
|
||||
return Response(path)
|
||||
|
||||
@@ -93,7 +89,7 @@ class PassThroughPortMixin(object):
|
||||
Return all CablePaths which traverse a given pass-through port.
|
||||
"""
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
|
||||
cablepaths = CablePath.objects.filter(_nodes__contains=obj)
|
||||
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
@@ -214,6 +210,14 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
data = serializer.validated_data
|
||||
|
||||
if data['render'] == 'svg':
|
||||
# Determine attributes for highlighting devices (if any)
|
||||
highlight_params = []
|
||||
for param in request.GET.getlist('highlight'):
|
||||
try:
|
||||
highlight_params.append(param.split(':', 1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Render and return the elevation as an SVG drawing with the correct content type
|
||||
drawing = rack.get_elevation_svg(
|
||||
face=data['face'],
|
||||
@@ -222,7 +226,8 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
unit_height=data['unit_height'],
|
||||
legend_width=data['legend_width'],
|
||||
include_images=data['include_images'],
|
||||
base_url=request.build_absolute_uri('/')
|
||||
base_url=request.build_absolute_uri('/'),
|
||||
highlight_params=highlight_params
|
||||
)
|
||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||
|
||||
@@ -392,6 +397,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
pagination_class = StripCountAnnotationsPaginator
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
@@ -477,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
response = {m: None for m in napalm_methods}
|
||||
|
||||
config = get_config()
|
||||
username = config.NAPALM_USERNAME
|
||||
@@ -546,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet):
|
||||
|
||||
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filtersets.ConsolePortFilterSet
|
||||
@@ -555,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filterset_class = filtersets.ConsoleServerPortFilterSet
|
||||
@@ -564,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filtersets.PowerPortFilterSet
|
||||
@@ -573,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filterset_class = filtersets.PowerOutletFilterSet
|
||||
@@ -582,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
|
||||
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
|
||||
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
@@ -592,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related(
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filtersets.FrontPortFilterSet
|
||||
@@ -601,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
|
||||
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related(
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filtersets.RearPortFilterSet
|
||||
@@ -609,7 +615,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
|
||||
|
||||
class ModuleBayViewSet(NetBoxModelViewSet):
|
||||
queryset = ModuleBay.objects.prefetch_related('tags')
|
||||
queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
|
||||
serializer_class = serializers.ModuleBaySerializer
|
||||
filterset_class = filtersets.ModuleBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
@@ -646,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
|
||||
#
|
||||
|
||||
class CableViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
'termination_a', 'termination_b'
|
||||
)
|
||||
queryset = Cable.objects.prefetch_related('terminations__termination')
|
||||
serializer_class = serializers.CableSerializer
|
||||
filterset_class = filtersets.CableFilterSet
|
||||
|
||||
|
||||
class CableTerminationViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CableTermination.objects.prefetch_related('cable', 'termination')
|
||||
serializer_class = serializers.CableTerminationSerializer
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
@@ -687,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
|
||||
|
||||
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related(
|
||||
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerFeedSerializer
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
@@ -747,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
device=peer_device,
|
||||
name=peer_interface_name
|
||||
)
|
||||
endpoint = peer_interface.connected_endpoint
|
||||
endpoints = peer_interface.connected_endpoints
|
||||
|
||||
# If an Interface, return the parent device
|
||||
if type(endpoint) is Interface:
|
||||
if endpoints and type(endpoints[0]) is Interface:
|
||||
device = get_object_or_404(
|
||||
Device.objects.restrict(request.user, 'view'),
|
||||
pk=endpoint.device_id
|
||||
pk=endpoints[0].device_id
|
||||
)
|
||||
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class DCIMConfig(AppConfig):
|
||||
name = "dcim"
|
||||
verbose_name = "DCIM"
|
||||
|
||||
def ready(self):
|
||||
|
||||
import dcim.signals
|
||||
from .models import CableTermination
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(CableTermination, '_device', {
|
||||
'_rack': 'rack',
|
||||
'_location': 'location',
|
||||
'_site': 'site',
|
||||
})
|
||||
denormalized.register(CableTermination, '_rack', {
|
||||
'_location': 'location',
|
||||
'_site': 'site',
|
||||
})
|
||||
denormalized.register(CableTermination, '_location', {
|
||||
'_site': 'site',
|
||||
})
|
||||
|
||||
@@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationStatusChoices(ChoiceSet):
|
||||
key = 'Location.status'
|
||||
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGING = 'staging'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
STATUS_RETIRED = 'retired'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_STAGING, 'Staging', 'blue'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||
(STATUS_RETIRED, 'Retired', 'red'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
@@ -159,6 +181,7 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
|
||||
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
|
||||
AIRFLOW_PASSIVE = 'passive'
|
||||
AIRFLOW_MIXED = 'mixed'
|
||||
|
||||
CHOICES = (
|
||||
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
|
||||
@@ -167,6 +190,7 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
|
||||
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
|
||||
(AIRFLOW_PASSIVE, 'Passive'),
|
||||
(AIRFLOW_MIXED, 'Mixed'),
|
||||
)
|
||||
|
||||
|
||||
@@ -352,6 +376,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -469,6 +494,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -575,8 +601,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
|
||||
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -683,9 +711,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
|
||||
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -995,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class InterfacePoEModeChoices(ChoiceSet):
|
||||
|
||||
MODE_PD = 'pd'
|
||||
MODE_PSE = 'pse'
|
||||
|
||||
CHOICES = (
|
||||
(MODE_PD, 'PD'),
|
||||
(MODE_PSE, 'PSE'),
|
||||
)
|
||||
|
||||
|
||||
class InterfacePoETypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||
|
||||
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
|
||||
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
|
||||
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
|
||||
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
'IEEE Standard',
|
||||
(
|
||||
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Passive',
|
||||
(
|
||||
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
|
||||
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# FrontPorts/RearPorts
|
||||
#
|
||||
@@ -1043,6 +1118,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_URM_P2 = 'urm-p2'
|
||||
TYPE_URM_P4 = 'urm-p4'
|
||||
TYPE_URM_P8 = 'urm-p8'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@@ -1095,6 +1171,12 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_URM_P4, 'URM-P4'),
|
||||
(TYPE_URM_P8, 'URM-P8'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
),
|
||||
),
|
||||
(
|
||||
'Other',
|
||||
(
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1200,6 +1282,22 @@ class CableLengthUnitChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# CableTerminations
|
||||
#
|
||||
|
||||
class CableEndChoices(ChoiceSet):
|
||||
|
||||
SIDE_A = 'A'
|
||||
SIDE_B = 'B'
|
||||
|
||||
CHOICES = (
|
||||
(SIDE_A, 'A'),
|
||||
(SIDE_B, 'B'),
|
||||
# ('', ''),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# PowerFeeds
|
||||
#
|
||||
|
||||
@@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
|
||||
RACK_U_HEIGHT_DEFAULT = 42
|
||||
|
||||
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
||||
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
||||
|
||||
|
||||
#
|
||||
@@ -49,19 +50,12 @@ WIRELESS_IFACE_TYPES = [
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
POWERFEED_VOLTAGE_DEFAULT = 120
|
||||
POWERFEED_AMPERAGE_DEFAULT = 20
|
||||
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
model__in=(
|
||||
@@ -91,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q(
|
||||
# Cabling and connections
|
||||
#
|
||||
|
||||
CABLE_TRACE_SVG_DEFAULT_WIDTH = 400
|
||||
|
||||
# Cable endpoint types
|
||||
CABLE_TERMINATION_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=(
|
||||
|
||||
@@ -21,6 +21,7 @@ from .models import *
|
||||
|
||||
__all__ = (
|
||||
'CableFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
@@ -163,7 +164,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
qs_filter |= Q(asns__asn=int(value.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
@@ -216,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
|
||||
to_field_name='slug',
|
||||
label='Location (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=LocationStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
fields = ['id', 'name', 'slug', 'status', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -307,7 +312,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
serial = django_filters.CharFilter(
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
@@ -346,6 +351,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='rack__site__region',
|
||||
lookup_expr='in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='rack__site__region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='rack__site__group',
|
||||
lookup_expr='in',
|
||||
label='Site group (ID)',
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='rack__site__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Site group (slug)',
|
||||
)
|
||||
location_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='rack__location',
|
||||
@@ -621,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
poe_mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoEModeChoices
|
||||
)
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@@ -966,14 +1003,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='model',
|
||||
label='Module type (model)',
|
||||
)
|
||||
module_bay_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_bay',
|
||||
queryset=ModuleBay.objects.all(),
|
||||
to_field_name='id',
|
||||
label='Module Bay (ID)'
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'serial', 'asset_tag']
|
||||
fields = ['id', 'asset_tag']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1081,7 +1127,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(django_filters.FilterSet):
|
||||
class CabledObjectFilterSet(django_filters.FilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
@@ -1104,7 +1150,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
class ConsolePortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1114,13 +1160,13 @@ class ConsolePortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
fields = ['id', 'name', 'label', 'description', 'cable_end']
|
||||
|
||||
|
||||
class ConsoleServerPortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1130,13 +1176,13 @@ class ConsoleServerPortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
fields = ['id', 'name', 'label', 'description', 'cable_end']
|
||||
|
||||
|
||||
class PowerPortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1146,13 +1192,13 @@ class PowerPortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
|
||||
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
|
||||
|
||||
|
||||
class PowerOutletFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1166,13 +1212,13 @@ class PowerOutletFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description']
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
|
||||
|
||||
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
@@ -1212,6 +1258,12 @@ class InterfaceFilterSet(
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter()
|
||||
wwn = MultiValueWWNFilter()
|
||||
poe_mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoEModeChoices
|
||||
)
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label='Assigned VLAN'
|
||||
@@ -1245,8 +1297,8 @@ class InterfaceFilterSet(
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
||||
]
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
@@ -1300,7 +1352,7 @@ class InterfaceFilterSet(
|
||||
class FrontPortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet
|
||||
CabledObjectFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@@ -1309,13 +1361,13 @@ class FrontPortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['id', 'name', 'label', 'type', 'color', 'description']
|
||||
fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
|
||||
|
||||
|
||||
class RearPortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet
|
||||
CabledObjectFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@@ -1324,7 +1376,7 @@ class RearPortFilterSet(
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
|
||||
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
|
||||
|
||||
|
||||
class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
|
||||
@@ -1368,7 +1420,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
|
||||
)
|
||||
component_type = ContentTypeFilter()
|
||||
component_id = MultiValueNumberFilter()
|
||||
serial = django_filters.CharFilter(
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
@@ -1472,10 +1524,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
termination_a_type = ContentTypeFilter()
|
||||
termination_a_id = MultiValueNumberFilter()
|
||||
termination_b_type = ContentTypeFilter()
|
||||
termination_b_id = MultiValueNumberFilter()
|
||||
termination_a_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_a_id = MultiValueNumberFilter(
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
termination_b_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_b_id = MultiValueNumberFilter(
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@@ -1486,44 +1546,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
choices=ColorChoices
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
method='filter_by_termination'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
method='filter_by_termination',
|
||||
field_name='device__name'
|
||||
)
|
||||
rack_id = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__rack_id'
|
||||
method='filter_by_termination',
|
||||
field_name='rack_id'
|
||||
)
|
||||
rack = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__rack__name'
|
||||
method='filter_by_termination',
|
||||
field_name='rack__name'
|
||||
)
|
||||
location_id = MultiValueNumberFilter(
|
||||
method='filter_by_termination',
|
||||
field_name='location_id'
|
||||
)
|
||||
location = MultiValueCharFilter(
|
||||
method='filter_by_termination',
|
||||
field_name='location__name'
|
||||
)
|
||||
site_id = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__site_id'
|
||||
method='filter_by_termination',
|
||||
field_name='site_id'
|
||||
)
|
||||
site = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__site__slug'
|
||||
method='filter_by_termination',
|
||||
field_name='site__slug'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
|
||||
fields = ['id', 'label', 'length', 'length_unit']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(label__icontains=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
queryset = queryset.filter(
|
||||
Q(**{'_termination_a_{}__in'.format(name): value}) |
|
||||
Q(**{'_termination_b_{}__in'.format(name): value})
|
||||
)
|
||||
return queryset
|
||||
def filter_by_termination(self, queryset, name, value):
|
||||
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
|
||||
# Supported objects: device, rack, location, site
|
||||
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
|
||||
|
||||
|
||||
class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
@@ -1583,7 +1656,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='power_panel__site__region',
|
||||
@@ -1637,7 +1710,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
|
||||
fields = [
|
||||
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(
|
||||
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
|
||||
form_from_model(Interface, [
|
||||
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
|
||||
]),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = Interface
|
||||
field_order = (
|
||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
|
||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
|
||||
'poe_type', 'mark_connected', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, VLAN, VRF
|
||||
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(LocationStatusChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
@@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Location
|
||||
fieldsets = (
|
||||
(None, ('site', 'parent', 'tenant', 'description')),
|
||||
(None, ('site', 'parent', 'status', 'tenant', 'description')),
|
||||
)
|
||||
nullable_fields = ('parent', 'tenant', 'description')
|
||||
|
||||
@@ -812,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
|
||||
description = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE type'
|
||||
)
|
||||
|
||||
nullable_fields = ('label', 'description')
|
||||
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
|
||||
|
||||
|
||||
class FrontPortTemplateBulkEditForm(BulkEditForm):
|
||||
@@ -1063,17 +1083,50 @@ class InterfaceBulkEditForm(
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
label='Management only'
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE type'
|
||||
)
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -1086,14 +1139,15 @@ class InterfaceBulkEditForm(
|
||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
||||
'invalid_choice': 'Location not found.',
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=LocationStatusChoices,
|
||||
help_text='Operational status'
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
@@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
class RackRoleCSVForm(NetBoxModelCSVForm):
|
||||
@@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
||||
choices=InterfaceDuplexChoices,
|
||||
required=False
|
||||
)
|
||||
poe_mode = CSVChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
help_text='PoE mode'
|
||||
)
|
||||
poe_type = CSVChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
help_text='PoE type'
|
||||
)
|
||||
mode = CSVChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
@@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
|
||||
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power',
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -941,7 +955,7 @@ class CableCSVForm(NetBoxModelCSVForm):
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
setattr(self.instance, f'termination_{side}', termination_object)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
def clean_side_a_name(self):
|
||||
|
||||
@@ -1,279 +1,170 @@
|
||||
from django import forms
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, Provider
|
||||
from dcim.models import *
|
||||
from extras.models import Tag
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
|
||||
|
||||
__all__ = (
|
||||
'ConnectCableToCircuitTerminationForm',
|
||||
'ConnectCableToConsolePortForm',
|
||||
'ConnectCableToConsoleServerPortForm',
|
||||
'ConnectCableToFrontPortForm',
|
||||
'ConnectCableToInterfaceForm',
|
||||
'ConnectCableToPowerFeedForm',
|
||||
'ConnectCableToPowerPortForm',
|
||||
'ConnectCableToPowerOutletForm',
|
||||
'ConnectCableToRearPortForm',
|
||||
)
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .models import CableForm
|
||||
|
||||
|
||||
class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
|
||||
"""
|
||||
Base form for connecting a Cable to a Device component
|
||||
"""
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
label='Location',
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$termination_b_site'
|
||||
}
|
||||
)
|
||||
termination_b_rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$termination_b_site',
|
||||
'location_id': '$termination_b_location',
|
||||
}
|
||||
)
|
||||
termination_b_device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device',
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$termination_b_site',
|
||||
'location_id': '$termination_b_location',
|
||||
'rack_id': '$termination_b_rack',
|
||||
}
|
||||
)
|
||||
def get_cable_form(a_type, b_type):
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
|
||||
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect,
|
||||
'type': StaticSelect,
|
||||
'length_unit': StaticSelect,
|
||||
}
|
||||
class FormMetaclass(forms.models.ModelFormMetaclass):
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
# Return the PK rather than the object
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
|
||||
|
||||
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=ConsolePort.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': f'$termination_{cable_end}_region',
|
||||
'group_id': f'$termination_{cable_end}_sitegroup',
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
label='Location',
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
|
||||
# Device component
|
||||
if hasattr(term_cls, 'device'):
|
||||
|
||||
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=ConsoleServerPort.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
required=False,
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'devices': f'$termination_{cable_end}_device'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device',
|
||||
required=False,
|
||||
initial_params={
|
||||
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
'rack_id': f'$termination_{cable_end}_rack',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
queryset=term_cls.objects.all(),
|
||||
label=term_cls._meta.verbose_name.title(),
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': f'$termination_{cable_end}_device',
|
||||
}
|
||||
)
|
||||
|
||||
# PowerFeed
|
||||
elif term_cls == PowerFeed:
|
||||
|
||||
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
label='Power Panel',
|
||||
required=False,
|
||||
initial_params={
|
||||
'powerfeeds__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
queryset=term_cls.objects.all(),
|
||||
label='Power Feed',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
|
||||
}
|
||||
)
|
||||
|
||||
# CircuitTermination
|
||||
elif term_cls == CircuitTermination:
|
||||
|
||||
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
initial_params={
|
||||
'circuits': f'$termination_{cable_end}_circuit'
|
||||
},
|
||||
required=False
|
||||
)
|
||||
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
label='Circuit',
|
||||
initial_params={
|
||||
'terminations__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'provider_id': f'$termination_{cable_end}_provider',
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
queryset=term_cls.objects.all(),
|
||||
label='Side',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'circuit_id': f'$termination_{cable_end}_circuit',
|
||||
}
|
||||
)
|
||||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device',
|
||||
'kind': 'physical',
|
||||
}
|
||||
)
|
||||
class _CableForm(CableForm, metaclass=FormMetaclass):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
|
||||
for field_name in ('a_terminations', 'b_terminations'):
|
||||
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
|
||||
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=RearPort.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'device_id': '$termination_b_device'
|
||||
}
|
||||
)
|
||||
if self.instance and self.instance.pk:
|
||||
# Initialize A/B terminations when modifying an existing Cable instance
|
||||
self.initial['a_terminations'] = self.instance.a_terminations
|
||||
self.initial['b_terminations'] = self.instance.b_terminations
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
|
||||
termination_b_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
required=False
|
||||
)
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_circuit = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
label='Circuit',
|
||||
query_params={
|
||||
'provider_id': '$termination_b_provider',
|
||||
'site_id': '$termination_b_site',
|
||||
}
|
||||
)
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=CircuitTermination.objects.all(),
|
||||
label='Side',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'circuit_id': '$termination_b_circuit'
|
||||
}
|
||||
)
|
||||
# Set the A/B terminations on the Cable instance
|
||||
self.instance.a_terminations = self.cleaned_data['a_terminations']
|
||||
self.instance.b_terminations = self.cleaned_data['b_terminations']
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
|
||||
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
# Return the PK rather than the object
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
label='Location',
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$termination_b_site'
|
||||
}
|
||||
)
|
||||
termination_b_powerpanel = DynamicModelChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
label='Power Panel',
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$termination_b_site',
|
||||
'location_id': '$termination_b_location',
|
||||
}
|
||||
)
|
||||
termination_b_id = DynamicModelChoiceField(
|
||||
queryset=PowerFeed.objects.all(),
|
||||
label='Name',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'power_panel_id': '$termination_b_powerpanel'
|
||||
}
|
||||
)
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
|
||||
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
|
||||
'color', 'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
# Return the PK rather than the object
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
return _CableForm
|
||||
|
||||
@@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Region
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = SiteGroup
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
@@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -166,9 +166,9 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
model = Location
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
|
||||
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
},
|
||||
label=_('Parent')
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=LocationStatusChoices,
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -210,11 +214,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_id', 'location_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Function', ('status', 'role_id')),
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -229,6 +233,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
@@ -282,7 +291,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('User', ('user_id',)),
|
||||
('Rack', ('region_id', 'site_id', 'location_id')),
|
||||
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -290,20 +299,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.prefetch_related('site'),
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_('Location'),
|
||||
null_option='None'
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
label=_('Rack')
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
@@ -319,7 +346,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Manufacturer
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -508,7 +535,7 @@ class DeviceFilterForm(
|
||||
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
|
||||
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Components', (
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
)),
|
||||
@@ -716,7 +743,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Cable
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('site_id', 'rack_id', 'device_id')),
|
||||
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
|
||||
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
@@ -733,13 +760,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
}
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
@@ -747,8 +784,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
'location_id': '$location_id',
|
||||
'rack_id': '$rack_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@@ -778,7 +816,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -959,6 +997,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
||||
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||
)
|
||||
@@ -972,8 +1011,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
label='Select Speed',
|
||||
widget=SelectSpeedWidget(attrs={'readonly': None})
|
||||
label='Speed',
|
||||
widget=SelectSpeedWidget()
|
||||
)
|
||||
duplex = MultipleChoiceField(
|
||||
choices=InterfaceDuplexChoices,
|
||||
@@ -999,6 +1038,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label='WWN'
|
||||
)
|
||||
poe_mode = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
rf_role = MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
@@ -1092,7 +1141,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Location', (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
@@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
||||
'tags',
|
||||
)
|
||||
widgets = {
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class RackRoleForm(NetBoxModelForm):
|
||||
@@ -321,7 +325,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
|
||||
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
@@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
position = forms.IntegerField(
|
||||
position = forms.DecimalField(
|
||||
required=False,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
@@ -521,13 +525,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=''
|
||||
)
|
||||
virtual_chassis = DynamicModelChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
required=False
|
||||
)
|
||||
vc_position = forms.IntegerField(
|
||||
required=False,
|
||||
label='Position',
|
||||
help_text="The position in the virtual chassis this device is identified by"
|
||||
)
|
||||
vc_priority = forms.IntegerField(
|
||||
required=False,
|
||||
label='Priority',
|
||||
help_text="The priority of the device in the virtual chassis"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
|
||||
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'comments', 'tags', 'local_context_data'
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
@@ -633,12 +652,18 @@ class ModuleForm(NetBoxModelForm):
|
||||
help_text="Automatically populate components associated with this module type"
|
||||
)
|
||||
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text="Adopt already existing components"
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Module', (
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
|
||||
)),
|
||||
('Hardware', (
|
||||
'serial', 'asset_tag', 'replicate_components',
|
||||
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -646,7 +671,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
model = Module
|
||||
fields = [
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
|
||||
'replicate_components', 'comments',
|
||||
'replicate_components', 'adopt_components', 'comments',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -655,6 +680,8 @@ class ModuleForm(NetBoxModelForm):
|
||||
if self.instance.pk:
|
||||
self.fields['replicate_components'].initial = False
|
||||
self.fields['replicate_components'].disabled = True
|
||||
self.fields['adopt_components'].initial = False
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -662,8 +689,62 @@ class ModuleForm(NetBoxModelForm):
|
||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
||||
self.instance._disable_replication = True
|
||||
|
||||
if self.cleaned_data['adopt_components']:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
@@ -971,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'poe_mode': StaticSelect(),
|
||||
'poe_type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1252,6 +1335,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', (
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
@@ -1262,14 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf', 'tags',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'speed': SelectSpeedWidget(),
|
||||
'poe_mode': StaticSelect(),
|
||||
'poe_type': StaticSelect(),
|
||||
'duplex': StaticSelect(),
|
||||
'mode': StaticSelect(),
|
||||
'rf_role': StaticSelect(),
|
||||
@@ -1284,6 +1370,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
'rf_channel_width': "Populated by selected channel (if set)",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Restrict LAG/bridge interface assignment by device/VC
|
||||
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
|
||||
device = Device.objects.filter(pk=device_id).first()
|
||||
if device and device.virtual_chassis and device.virtual_chassis.master:
|
||||
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
|
||||
|
||||
class FrontPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
|
||||
@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
|
||||
"""
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name',
|
||||
help_text="""
|
||||
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
|
||||
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
|
||||
the module bay position.
|
||||
"""
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False
|
||||
@@ -256,6 +264,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
|
||||
raise forms.ValidationError({
|
||||
'initial_position': "A position must be specified for the first VC member."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
|
||||
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
|
||||
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
|
||||
from dcim.models import *
|
||||
from utilities.forms import BootstrapMixin
|
||||
|
||||
@@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=InterfaceTypeChoices.CHOICES
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
|
||||
]
|
||||
|
||||
|
||||
|
||||
5
netbox/dcim/graphql/mixins.py
Normal file
5
netbox/dcim/graphql/mixins.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class CabledObjectMixin:
|
||||
|
||||
def resolve_cable_end(self, info):
|
||||
# Handle empty values
|
||||
return self.cable_end or None
|
||||
@@ -7,6 +7,7 @@ from extras.graphql.mixins import (
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
from .mixins import CabledObjectMixin
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
@@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
|
||||
return self.length_unit or None
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType):
|
||||
class CableTerminationType(NetBoxObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CableTermination
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(ComponentObjectType):
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
|
||||
return self.airflow or None
|
||||
|
||||
|
||||
class FrontPortType(ComponentObjectType):
|
||||
class FrontPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.FrontPort
|
||||
@@ -219,13 +228,19 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
exclude = ('_path',)
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
|
||||
def resolve_poe_mode(self, info):
|
||||
return self.poe_mode or None
|
||||
|
||||
def resolve_poe_type(self, info):
|
||||
return self.poe_type or None
|
||||
|
||||
def resolve_mode(self, info):
|
||||
return self.mode or None
|
||||
|
||||
@@ -243,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.InterfaceTemplateFilterSet
|
||||
|
||||
def resolve_poe_mode(self, info):
|
||||
return self.poe_mode or None
|
||||
|
||||
def resolve_poe_type(self, info):
|
||||
return self.poe_type or None
|
||||
|
||||
|
||||
class InventoryItemType(ComponentObjectType):
|
||||
|
||||
@@ -316,7 +337,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@@ -324,7 +345,7 @@ class PowerFeedType(NetBoxObjectType):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@@ -360,7 +381,7 @@ class PowerPanelType(NetBoxObjectType):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
@@ -412,7 +433,7 @@ class RackRoleType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.RackRoleFilterSet
|
||||
|
||||
|
||||
class RearPortType(ComponentObjectType):
|
||||
class RearPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.RearPort
|
||||
|
||||
@@ -81,7 +81,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
||||
i = 0
|
||||
for i, obj in enumerate(origins, start=1):
|
||||
create_cablepath(obj)
|
||||
create_cablepath([obj])
|
||||
if not i % 100:
|
||||
self.draw_progress_bar(i * 100 / origins_count)
|
||||
self.draw_progress_bar(100)
|
||||
|
||||
@@ -386,9 +386,9 @@ class Migration(migrations.Migration):
|
||||
('type', models.CharField(default='primary', max_length=50)),
|
||||
('supply', models.CharField(default='ac', max_length=50)),
|
||||
('phase', models.CharField(default='single-phase', max_length=50)),
|
||||
('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])),
|
||||
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||
('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])),
|
||||
('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||
('available_power', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
],
|
||||
|
||||
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0153_created_datetimefield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='u_height',
|
||||
field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='position',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
|
||||
),
|
||||
]
|
||||
33
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
33
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-22 00:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0154_half_height_rack_units'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='poe_mode',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='poe_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='poe_mode',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='poe_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
]
|
||||
18
netbox/dcim/migrations/0156_location_status.py
Normal file
18
netbox/dcim/migrations/0156_location_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-22 17:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0155_interface_poe_mode_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='location',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
||||
95
netbox/dcim/migrations/0157_new_cabling_models.py
Normal file
95
netbox/dcim/migrations/0157_new_cabling_models.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0156_location_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
# Create CableTermination model
|
||||
migrations.CreateModel(
|
||||
name='CableTermination',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('cable_end', models.CharField(max_length=1)),
|
||||
('termination_id', models.PositiveBigIntegerField()),
|
||||
('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
|
||||
('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
|
||||
('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
|
||||
('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
|
||||
('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
|
||||
('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('cable', 'cable_end', 'pk'),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='cabletermination',
|
||||
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
|
||||
),
|
||||
|
||||
# Update CablePath model
|
||||
migrations.RenameField(
|
||||
model_name='cablepath',
|
||||
old_name='path',
|
||||
new_name='_nodes',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cablepath',
|
||||
name='path',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cablepath',
|
||||
name='is_complete',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
|
||||
# Add cable_end field to cable termination models
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
]
|
||||
87
netbox/dcim/migrations/0158_populate_cable_terminations.py
Normal file
87
netbox/dcim/migrations/0158_populate_cable_terminations.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import sys
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def cache_related_objects(termination):
|
||||
"""
|
||||
Replicate caching logic from CableTermination.cache_related_objects()
|
||||
"""
|
||||
attrs = {}
|
||||
|
||||
# Device components
|
||||
if getattr(termination, 'device', None):
|
||||
attrs['_device'] = termination.device
|
||||
attrs['_rack'] = termination.device.rack
|
||||
attrs['_location'] = termination.device.location
|
||||
attrs['_site'] = termination.device.site
|
||||
|
||||
# Power feeds
|
||||
elif getattr(termination, 'rack', None):
|
||||
attrs['_rack'] = termination.rack
|
||||
attrs['_location'] = termination.rack.location
|
||||
attrs['_site'] = termination.rack.site
|
||||
|
||||
# Circuit terminations
|
||||
elif getattr(termination, 'site', None):
|
||||
attrs['_site'] = termination.site
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
def populate_cable_terminations(apps, schema_editor):
|
||||
"""
|
||||
Replicate terminations from the Cable model into CableTermination instances.
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
CableTermination = apps.get_model('dcim', 'CableTermination')
|
||||
|
||||
# Retrieve the necessary data from Cable objects
|
||||
cables = Cable.objects.values(
|
||||
'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id'
|
||||
)
|
||||
|
||||
# Queue CableTerminations to be created
|
||||
cable_terminations = []
|
||||
cable_count = cables.count()
|
||||
for i, cable in enumerate(cables, start=1):
|
||||
for cable_end in ('a', 'b'):
|
||||
# We must manually instantiate the termination object, because GFK fields are not
|
||||
# supported within migrations.
|
||||
termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type'])
|
||||
termination_model = apps.get_model(termination_ct.app_label, termination_ct.model)
|
||||
termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id'])
|
||||
|
||||
cable_terminations.append(CableTermination(
|
||||
cable_id=cable['id'],
|
||||
cable_end=cable_end.upper(),
|
||||
termination_type_id=cable[f'termination_{cable_end}_type'],
|
||||
termination_id=cable[f'termination_{cable_end}_id'],
|
||||
**cache_related_objects(termination)
|
||||
))
|
||||
|
||||
# Output progress occasionally
|
||||
if 'test' not in sys.argv and not i % 100:
|
||||
progress = float(i) * 100 / cable_count
|
||||
if i == 100:
|
||||
print('')
|
||||
sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Bulk create the termination objects
|
||||
CableTermination.objects.bulk_create(cable_terminations, batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0157_new_cabling_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=populate_cable_terminations,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
50
netbox/dcim/migrations/0159_populate_cable_paths.py
Normal file
50
netbox/dcim/migrations/0159_populate_cable_paths.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations
|
||||
|
||||
from dcim.utils import compile_path_node
|
||||
|
||||
|
||||
def populate_cable_paths(apps, schema_editor):
|
||||
"""
|
||||
Replicate terminations from the Cable model into CableTermination instances.
|
||||
"""
|
||||
CablePath = apps.get_model('dcim', 'CablePath')
|
||||
|
||||
# Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
|
||||
cable_paths = []
|
||||
for cablepath in CablePath.objects.all():
|
||||
|
||||
# Origin
|
||||
origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
|
||||
cablepath.path.append([origin])
|
||||
cablepath._nodes.insert(0, origin)
|
||||
|
||||
# Transit nodes
|
||||
cablepath.path.extend([
|
||||
[node] for node in cablepath._nodes[1:]
|
||||
])
|
||||
|
||||
# Destination
|
||||
if cablepath.destination_id:
|
||||
destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
|
||||
cablepath.path.append([destination])
|
||||
cablepath._nodes.append(destination)
|
||||
cablepath.is_complete = True
|
||||
|
||||
cable_paths.append(cablepath)
|
||||
|
||||
# Bulk update all CableTerminations
|
||||
CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0158_populate_cable_terminations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=populate_cable_paths,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
46
netbox/dcim/migrations/0160_populate_cable_ends.py
Normal file
46
netbox/dcim/migrations/0160_populate_cable_ends.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_cable_terminations(apps, schema_editor):
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
cable_termination_models = (
|
||||
apps.get_model('dcim', 'ConsolePort'),
|
||||
apps.get_model('dcim', 'ConsoleServerPort'),
|
||||
apps.get_model('dcim', 'PowerPort'),
|
||||
apps.get_model('dcim', 'PowerOutlet'),
|
||||
apps.get_model('dcim', 'Interface'),
|
||||
apps.get_model('dcim', 'FrontPort'),
|
||||
apps.get_model('dcim', 'RearPort'),
|
||||
apps.get_model('dcim', 'PowerFeed'),
|
||||
apps.get_model('circuits', 'CircuitTermination'),
|
||||
)
|
||||
|
||||
for model in cable_termination_models:
|
||||
model.objects.filter(
|
||||
id__in=Cable.objects.filter(
|
||||
termination_a_type__app_label=model._meta.app_label,
|
||||
termination_a_type__model=model._meta.model_name
|
||||
).values_list('termination_a_id', flat=True)
|
||||
).update(cable_end='A')
|
||||
model.objects.filter(
|
||||
id__in=Cable.objects.filter(
|
||||
termination_b_type__app_label=model._meta.app_label,
|
||||
termination_b_type__model=model._meta.model_name
|
||||
).values_list('termination_b_id', flat=True)
|
||||
).update(cable_end='B')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0037_new_cabling_models'),
|
||||
('dcim', '0159_populate_cable_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=populate_cable_terminations,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
134
netbox/dcim/migrations/0161_cabling_cleanup.py
Normal file
134
netbox/dcim/migrations/0161_cabling_cleanup.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0160_populate_cable_ends'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
# Remove old fields from Cable
|
||||
migrations.AlterModelOptions(
|
||||
name='cable',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='cable',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_a_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_a_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_b_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_b_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='_termination_a_device',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='_termination_b_device',
|
||||
),
|
||||
|
||||
# Remove old fields from CablePath
|
||||
migrations.AlterUniqueTogether(
|
||||
name='cablepath',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='destination_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='destination_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='origin_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='origin_type',
|
||||
),
|
||||
|
||||
# Remove link peer type/ID fields from cable termination models
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleserverport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleserverport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='frontport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='frontport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='poweroutlet',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='poweroutlet',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='rearport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='rearport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
|
||||
]
|
||||
@@ -1,10 +1,12 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import Signal
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.choices import *
|
||||
@@ -13,17 +15,21 @@ from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
|
||||
from netbox.models import NetBoxModel
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from .devices import Device
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
'CablePath',
|
||||
'CableTermination',
|
||||
)
|
||||
|
||||
|
||||
trace_paths = Signal()
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
@@ -32,28 +38,6 @@ class Cable(NetBoxModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
"""
|
||||
termination_a_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_a_id = models.PositiveBigIntegerField()
|
||||
termination_a = GenericForeignKey(
|
||||
ct_field='termination_a_type',
|
||||
fk_field='termination_a_id'
|
||||
)
|
||||
termination_b_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_b_id = models.PositiveBigIntegerField()
|
||||
termination_b = GenericForeignKey(
|
||||
ct_field='termination_b_type',
|
||||
fk_field='termination_b_id'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableTypeChoices,
|
||||
@@ -96,31 +80,11 @@ class Cable(NetBoxModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
|
||||
# their associated Devices.
|
||||
_termination_a_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_termination_b_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
('termination_a_type', 'termination_a_id'),
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
ordering = ('pk',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
@@ -129,19 +93,13 @@ class Cable(NetBoxModel):
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db, field_names, values):
|
||||
"""
|
||||
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
|
||||
"""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
self._terminations_modified = False
|
||||
|
||||
instance._orig_termination_a_type_id = instance.termination_a_type_id
|
||||
instance._orig_termination_a_id = instance.termination_a_id
|
||||
instance._orig_termination_b_type_id = instance.termination_b_type_id
|
||||
instance._orig_termination_b_id = instance.termination_b_id
|
||||
|
||||
return instance
|
||||
# Assign or retrieve A/B terminations
|
||||
if a_terminations:
|
||||
self.a_terminations = a_terminations
|
||||
if b_terminations:
|
||||
self.b_terminations = b_terminations
|
||||
|
||||
def __str__(self):
|
||||
pk = self.pk or self._pk
|
||||
@@ -150,124 +108,68 @@ class Cable(NetBoxModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
@property
|
||||
def a_terminations(self):
|
||||
if hasattr(self, '_a_terminations'):
|
||||
return self._a_terminations
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
|
||||
]
|
||||
|
||||
@a_terminations.setter
|
||||
def a_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
self._a_terminations = value
|
||||
|
||||
@property
|
||||
def b_terminations(self):
|
||||
if hasattr(self, '_b_terminations'):
|
||||
return self._b_terminations
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
|
||||
]
|
||||
|
||||
@b_terminations.setter
|
||||
def b_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
self._b_terminations = value
|
||||
|
||||
def clean(self):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
raise ValidationError('Termination A type has not been specified')
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
if not hasattr(self, 'termination_b_type'):
|
||||
raise ValidationError('Termination B type has not been specified')
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
# If editing an existing Cable instance, check that neither termination has been modified.
|
||||
if self.pk:
|
||||
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
|
||||
if (
|
||||
self.termination_a_type_id != self._orig_termination_a_type_id or
|
||||
self.termination_a_id != self._orig_termination_a_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_a': err_msg
|
||||
})
|
||||
if (
|
||||
self.termination_b_type_id != self._orig_termination_b_type_id or
|
||||
self.termination_b_id != self._orig_termination_b_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_b': err_msg
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# Validate interface types
|
||||
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_a.get_type_display()
|
||||
)
|
||||
})
|
||||
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_b.get_type_display()
|
||||
)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError(
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# Check that two connected RearPorts have the same number of positions (if both are >1)
|
||||
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
|
||||
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
|
||||
if self.termination_a.positions != self.termination_b.positions:
|
||||
raise ValidationError(
|
||||
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
|
||||
f"{self.termination_b} has {self.termination_b.positions}. "
|
||||
f"Both terminations must have the same number of positions (if greater than one)."
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
|
||||
raise ValidationError({
|
||||
'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
|
||||
})
|
||||
if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
|
||||
raise ValidationError({
|
||||
'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
|
||||
})
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
for termination in self.b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
_created = self.pk is None
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
@@ -275,199 +177,447 @@ class Cable(NetBoxModel):
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
|
||||
if hasattr(self.termination_a, 'device'):
|
||||
self._termination_a_device = self.termination_a.device
|
||||
if hasattr(self.termination_b, 'device'):
|
||||
self._termination_b_device = self.termination_b.device
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
self._pk = self.pk
|
||||
|
||||
# Retrieve existing A/B terminations for the Cable
|
||||
a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
|
||||
b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
|
||||
|
||||
# Delete stale CableTerminations
|
||||
if self._terminations_modified:
|
||||
for termination, ct in a_terminations.items():
|
||||
if termination.pk and termination not in self.a_terminations:
|
||||
ct.delete()
|
||||
for termination, ct in b_terminations.items():
|
||||
if termination.pk and termination not in self.b_terminations:
|
||||
ct.delete()
|
||||
|
||||
# Save new CableTerminations (if any)
|
||||
if self._terminations_modified:
|
||||
for termination in self.a_terminations:
|
||||
if not termination.pk or termination not in a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).save()
|
||||
for termination in self.b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
|
||||
def get_status_color(self):
|
||||
return LinkStatusChoices.colors.get(self.status)
|
||||
|
||||
def get_compatible_types(self):
|
||||
|
||||
class CableTermination(models.Model):
|
||||
"""
|
||||
A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
|
||||
"""
|
||||
cable = models.ForeignKey(
|
||||
to='dcim.Cable',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='terminations'
|
||||
)
|
||||
cable_end = models.CharField(
|
||||
max_length=1,
|
||||
choices=CableEndChoices,
|
||||
verbose_name='End'
|
||||
)
|
||||
termination_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_id = models.PositiveBigIntegerField()
|
||||
termination = GenericForeignKey(
|
||||
ct_field='termination_type',
|
||||
fk_field='termination_id'
|
||||
)
|
||||
|
||||
# Cached associations to enable efficient filtering
|
||||
_device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_location = models.ForeignKey(
|
||||
to='dcim.Location',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('cable', 'cable_end', 'pk')
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('termination_type', 'termination_id'),
|
||||
name='dcim_cable_termination_unique_termination'
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'Cable {self.cable} to {self.termination}'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
|
||||
})
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError({
|
||||
'termination': "Circuit terminations attached to a provider network may not be cabled."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
self.cache_related_objects()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Set the cable on the terminating object
|
||||
termination_model = self.termination._meta.model
|
||||
termination_model.objects.filter(pk=self.termination_id).update(
|
||||
cable=self.cable,
|
||||
cable_end=self.cable_end
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
# Delete the cable association on the terminating object
|
||||
termination_model = self.termination._meta.model
|
||||
termination_model.objects.filter(pk=self.termination_id).update(
|
||||
cable=None,
|
||||
cable_end=''
|
||||
)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def cache_related_objects(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
Cache objects related to the termination (e.g. device, rack, site) directly on the object to
|
||||
enable efficient filtering.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
assert self.termination is not None
|
||||
|
||||
# Device components
|
||||
if getattr(self.termination, 'device', None):
|
||||
self._device = self.termination.device
|
||||
self._rack = self.termination.device.rack
|
||||
self._location = self.termination.device.location
|
||||
self._site = self.termination.device.site
|
||||
|
||||
# Power feeds
|
||||
elif getattr(self.termination, 'rack', None):
|
||||
self._rack = self.termination.rack
|
||||
self._location = self.termination.rack.location
|
||||
self._site = self.termination.rack.site
|
||||
|
||||
# Circuit terminations
|
||||
elif getattr(self.termination, 'site', None):
|
||||
self._site = self.termination.site
|
||||
|
||||
|
||||
class CablePath(models.Model):
|
||||
"""
|
||||
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
|
||||
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
|
||||
not terminate on a PathEndpoint).
|
||||
A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
|
||||
including all intermediate elements.
|
||||
|
||||
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
|
||||
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
|
||||
`path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
|
||||
terminate to one or more objects.) For example, consider the following
|
||||
topology:
|
||||
|
||||
1 2 3
|
||||
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
|
||||
A B C
|
||||
Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
|
||||
Front Port 2 Front Port 4
|
||||
|
||||
This path would be expressed as:
|
||||
|
||||
CablePath(
|
||||
origin = Interface A
|
||||
destination = Interface B
|
||||
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
|
||||
path = [
|
||||
[Interface 1],
|
||||
[Cable A],
|
||||
[Front Port 1, Front Port 2],
|
||||
[Rear Port 1],
|
||||
[Cable B],
|
||||
[Rear Port 2],
|
||||
[Front Port 3, Front Port 4],
|
||||
[Cable C],
|
||||
[Interface 2],
|
||||
]
|
||||
)
|
||||
|
||||
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
|
||||
"connected".
|
||||
`is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
|
||||
if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
|
||||
path diverges across multiple cables.
|
||||
|
||||
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
|
||||
"""
|
||||
origin_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+'
|
||||
path = models.JSONField(
|
||||
default=list
|
||||
)
|
||||
origin_id = models.PositiveBigIntegerField()
|
||||
origin = GenericForeignKey(
|
||||
ct_field='origin_type',
|
||||
fk_field='origin_id'
|
||||
)
|
||||
destination_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
destination_id = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
destination = GenericForeignKey(
|
||||
ct_field='destination_type',
|
||||
fk_field='destination_id'
|
||||
)
|
||||
path = PathField()
|
||||
is_active = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
is_complete = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
is_split = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('origin_type', 'origin_id')
|
||||
_nodes = PathField()
|
||||
|
||||
def __str__(self):
|
||||
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
|
||||
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
|
||||
return f"Path #{self.pk}: {len(self.path)} hops"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Save the flattened nodes list
|
||||
self._nodes = list(itertools.chain(*self.path))
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Record a direct reference to this CablePath on its originating object
|
||||
model = self.origin._meta.model
|
||||
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
|
||||
# Record a direct reference to this CablePath on its originating object(s)
|
||||
origin_model = self.origin_type.model_class()
|
||||
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
|
||||
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
|
||||
|
||||
@property
|
||||
def origin_type(self):
|
||||
if self.path:
|
||||
ct_id, _ = decompile_path_node(self.path[0][0])
|
||||
return ContentType.objects.get_for_id(ct_id)
|
||||
|
||||
@property
|
||||
def destination_type(self):
|
||||
if self.is_complete:
|
||||
ct_id, _ = decompile_path_node(self.path[-1][0])
|
||||
return ContentType.objects.get_for_id(ct_id)
|
||||
|
||||
@property
|
||||
def path_objects(self):
|
||||
"""
|
||||
Cache and return the complete path as lists of objects, derived from their annotation within the path.
|
||||
"""
|
||||
if not hasattr(self, '_path_objects'):
|
||||
self._path_objects = self._get_path()
|
||||
return self._path_objects
|
||||
|
||||
@property
|
||||
def origins(self):
|
||||
"""
|
||||
Return the list of originating objects.
|
||||
"""
|
||||
return self.path_objects[0]
|
||||
|
||||
@property
|
||||
def destinations(self):
|
||||
"""
|
||||
Return the list of destination objects, if the path is complete.
|
||||
"""
|
||||
if not self.is_complete:
|
||||
return []
|
||||
return self.path_objects[-1]
|
||||
|
||||
@property
|
||||
def segment_count(self):
|
||||
total_length = 1 + len(self.path) + (1 if self.destination else 0)
|
||||
return int(total_length / 3)
|
||||
return int(len(self.path) / 3)
|
||||
|
||||
@classmethod
|
||||
def from_origin(cls, origin):
|
||||
def from_origin(cls, terminations):
|
||||
"""
|
||||
Create a new CablePath instance as traced from the given path origin.
|
||||
Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
|
||||
Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
|
||||
of the same type and must belong to the same parent object.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
if origin is None or origin.link is None:
|
||||
if not terminations:
|
||||
return None
|
||||
|
||||
destination = None
|
||||
# Ensure all originating terminations are attached to the same link
|
||||
if len(terminations) > 1:
|
||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
||||
|
||||
path = []
|
||||
position_stack = []
|
||||
is_complete = False
|
||||
is_active = True
|
||||
is_split = False
|
||||
|
||||
node = origin
|
||||
while node.link is not None:
|
||||
if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
while terminations:
|
||||
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Step 1: Record the near-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
|
||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
||||
link = terminations[0].link
|
||||
if link is None and len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
elif link is None:
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert type(link) in (Cable, WirelessLink)
|
||||
|
||||
# Step 3: Record the link and update path status if not "connected"
|
||||
path.append([object_to_path_node(link)])
|
||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
is_active = False
|
||||
|
||||
# Follow the link to its far-end termination
|
||||
path.append(object_to_path_node(node.link))
|
||||
peer_termination = node.get_link_peer()
|
||||
# Step 4: Determine the far-end terminations
|
||||
if isinstance(link, Cable):
|
||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# Terminations must all belong to same end of Cable
|
||||
local_cable_end = local_cable_terminations[0].cable_end
|
||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
||||
remote_cable_terminations = CableTermination.objects.filter(
|
||||
cable=link,
|
||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
||||
)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
|
||||
|
||||
# Follow a FrontPort to its corresponding RearPort
|
||||
if isinstance(peer_termination, FrontPort):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
node = peer_termination.rear_port
|
||||
if node.positions > 1:
|
||||
position_stack.append(peer_termination.rear_port_position)
|
||||
path.append(object_to_path_node(node))
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations
|
||||
])
|
||||
|
||||
# Follow a RearPort to its corresponding FrontPort (if any)
|
||||
elif isinstance(peer_termination, RearPort):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
if not remote_terminations:
|
||||
break
|
||||
|
||||
# Determine the peer FrontPort's position
|
||||
if peer_termination.positions == 1:
|
||||
position = 1
|
||||
if isinstance(remote_terminations[0], FrontPort):
|
||||
# Follow FrontPorts to their corresponding RearPorts
|
||||
rear_ports = RearPort.objects.filter(
|
||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||
)
|
||||
if len(rear_ports) > 1:
|
||||
assert all(rp.positions == 1 for rp in rear_ports)
|
||||
elif rear_ports[0].positions > 1:
|
||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||
|
||||
terminations = rear_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], RearPort):
|
||||
|
||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||
rear_port_position=1
|
||||
)
|
||||
elif position_stack:
|
||||
position = position_stack.pop()
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
rear_port_position__in=position_stack.pop()
|
||||
)
|
||||
else:
|
||||
# No position indicated: path has split, so we stop at the RearPort
|
||||
# No position indicated: path has split, so we stop at the RearPorts
|
||||
is_split = True
|
||||
break
|
||||
|
||||
try:
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
|
||||
path.append(object_to_path_node(node))
|
||||
except ObjectDoesNotExist:
|
||||
# No corresponding FrontPort found for the RearPort
|
||||
terminations = front_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], CircuitTermination):
|
||||
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
|
||||
term_side = remote_terminations[0].term_side
|
||||
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
|
||||
circuit_termination = CircuitTermination.objects.filter(
|
||||
circuit=remote_terminations[0].circuit,
|
||||
term_side='Z' if term_side == 'A' else 'A'
|
||||
).first()
|
||||
if circuit_termination is None:
|
||||
break
|
||||
elif circuit_termination.provider_network:
|
||||
# Circuit terminates to a ProviderNetwork
|
||||
path.extend([
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.provider_network)],
|
||||
])
|
||||
break
|
||||
elif circuit_termination.site and not circuit_termination.cable:
|
||||
# Circuit terminates to a Site
|
||||
path.extend([
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.site)],
|
||||
])
|
||||
break
|
||||
|
||||
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
|
||||
elif isinstance(peer_termination, CircuitTermination):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
# Get peer CircuitTermination
|
||||
node = peer_termination.get_peer_termination()
|
||||
if node:
|
||||
path.append(object_to_path_node(node))
|
||||
if node.provider_network:
|
||||
destination = node.provider_network
|
||||
break
|
||||
elif node.site and not node.cable:
|
||||
destination = node.site
|
||||
break
|
||||
else:
|
||||
# No peer CircuitTermination exists; halt the trace
|
||||
break
|
||||
terminations = [circuit_termination]
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
destination = peer_termination
|
||||
is_complete = True
|
||||
break
|
||||
|
||||
if destination is None:
|
||||
is_active = False
|
||||
|
||||
return cls(
|
||||
origin=origin,
|
||||
destination=destination,
|
||||
path=path,
|
||||
is_complete=is_complete,
|
||||
is_active=is_active,
|
||||
is_split=is_split
|
||||
)
|
||||
|
||||
def get_path(self):
|
||||
def retrace(self):
|
||||
"""
|
||||
Retrace the path from the currently-defined originating termination(s)
|
||||
"""
|
||||
_new = self.from_origin(self.origins)
|
||||
if _new:
|
||||
self.path = _new.path
|
||||
self.is_complete = _new.is_complete
|
||||
self.is_active = _new.is_active
|
||||
self.is_split = _new.is_split
|
||||
self.save()
|
||||
else:
|
||||
self.delete()
|
||||
|
||||
def _get_path(self):
|
||||
"""
|
||||
Return the path as a list of prefetched objects.
|
||||
"""
|
||||
# Compile a list of IDs to prefetch for each type of model in the path
|
||||
to_prefetch = defaultdict(list)
|
||||
for node in self.path:
|
||||
for node in self._nodes:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
to_prefetch[ct_id].append(object_id)
|
||||
|
||||
@@ -484,19 +634,19 @@ class CablePath(models.Model):
|
||||
|
||||
# Replicate the path using the prefetched objects.
|
||||
path = []
|
||||
for node in self.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
path.append(prefetched[ct_id][object_id])
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
try:
|
||||
nodes.append(prefetched[ct_id][object_id])
|
||||
except KeyError:
|
||||
# Ignore stale (deleted) object IDs
|
||||
pass
|
||||
path.append(nodes)
|
||||
|
||||
return path
|
||||
|
||||
@property
|
||||
def last_node(self):
|
||||
"""
|
||||
Return either the destination or the last node within the path.
|
||||
"""
|
||||
return self.destination or path_node_to_object(self.path[-1])
|
||||
|
||||
def get_cable_ids(self):
|
||||
"""
|
||||
Return all Cable IDs within the path.
|
||||
@@ -504,7 +654,7 @@ class CablePath(models.Model):
|
||||
cable_ct = ContentType.objects.get_for_model(Cable).pk
|
||||
cable_ids = []
|
||||
|
||||
for node in self.path:
|
||||
for node in self._nodes:
|
||||
ct, id = decompile_path_node(node)
|
||||
if ct == cable_ct:
|
||||
cable_ids.append(id)
|
||||
@@ -527,6 +677,6 @@ class CablePath(models.Model):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
rearport = path_node_to_object(self.path[-1])
|
||||
rearport = path_node_to_object(self._nodes[-1])
|
||||
|
||||
return FrontPort.objects.filter(rear_port=rearport)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
|
||||
related_name='%(class)ss'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
max_length=64,
|
||||
help_text="""
|
||||
{module} is accepted as a substitution for the module bay position when attached to a module type.
|
||||
"""
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
@@ -121,12 +124,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
|
||||
def resolve_name(self, module):
|
||||
if module:
|
||||
return self.name.replace('{module}', module.module_bay.position)
|
||||
return self.name.replace(MODULE_TOKEN, module.module_bay.position)
|
||||
return self.name
|
||||
|
||||
def resolve_label(self, module):
|
||||
if module:
|
||||
return self.label.replace('{module}', module.module_bay.position)
|
||||
return self.label.replace(MODULE_TOKEN, module.module_bay.position)
|
||||
return self.label
|
||||
|
||||
|
||||
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'maximum_draw': self.maximum_draw,
|
||||
'allocated_draw': self.allocated_draw,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'power_port': self.power_port.name if self.power_port else None,
|
||||
'feed_leg': self.feed_leg,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -318,6 +357,18 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
default=False,
|
||||
verbose_name='Management only'
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoEModeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE mode'
|
||||
)
|
||||
poe_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoETypeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE type'
|
||||
)
|
||||
|
||||
component_model = Interface
|
||||
|
||||
@@ -334,9 +385,22 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
type=self.type,
|
||||
mgmt_only=self.mgmt_only,
|
||||
poe_mode=self.poe_mode,
|
||||
poe_type=self.poe_type,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'mgmt_only': self.mgmt_only,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
'poe_mode': self.poe_mode,
|
||||
'poe_type': self.poe_type,
|
||||
}
|
||||
|
||||
|
||||
class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -410,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'rear_port': self.rear_port.name,
|
||||
'rear_port_position': self.rear_port_position,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class RearPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -449,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'positions': self.positions,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -474,6 +557,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
||||
position=self.position
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'position': self.position,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -498,6 +589,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import MACAddressField, WWNField
|
||||
from dcim.svg import CableTraceSVG
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
|
||||
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'LinkTermination',
|
||||
'CabledObjectModel',
|
||||
'ConsolePort',
|
||||
'ConsoleServerPort',
|
||||
'DeviceBay',
|
||||
@@ -95,22 +96,17 @@ class ModularComponentModel(ComponentModel):
|
||||
inventory_items = GenericRelation(
|
||||
to='dcim.InventoryItem',
|
||||
content_type_field='component_type',
|
||||
object_id_field='component_id',
|
||||
related_name='%(class)ss',
|
||||
object_id_field='component_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class LinkTermination(models.Model):
|
||||
class CabledObjectModel(models.Model):
|
||||
"""
|
||||
An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
|
||||
include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
|
||||
reference the attached Cable or WirelessLink instance, respectively.
|
||||
|
||||
`_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
|
||||
shortcut to referencing `instance.link.termination_b`, for example.
|
||||
An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
|
||||
fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
|
||||
"""
|
||||
cable = models.ForeignKey(
|
||||
to='dcim.Cable',
|
||||
@@ -119,36 +115,21 @@ class LinkTermination(models.Model):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_link_peer_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
cable_end = models.CharField(
|
||||
max_length=1,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_link_peer_id = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_link_peer = GenericForeignKey(
|
||||
ct_field='_link_peer_type',
|
||||
fk_field='_link_peer_id'
|
||||
choices=CableEndChoices
|
||||
)
|
||||
mark_connected = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Treat as if a cable is connected"
|
||||
)
|
||||
|
||||
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
|
||||
_cabled_as_a = GenericRelation(
|
||||
to='dcim.Cable',
|
||||
content_type_field='termination_a_type',
|
||||
object_id_field='termination_a_id'
|
||||
)
|
||||
_cabled_as_b = GenericRelation(
|
||||
to='dcim.Cable',
|
||||
content_type_field='termination_b_type',
|
||||
object_id_field='termination_b_id'
|
||||
cable_terminations = GenericRelation(
|
||||
to='dcim.CableTermination',
|
||||
content_type_field='termination_type',
|
||||
object_id_field='termination_id',
|
||||
related_query_name='%(class)s',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -157,22 +138,19 @@ class LinkTermination(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.mark_connected and self.cable_id:
|
||||
if self.cable and not self.cable_end:
|
||||
raise ValidationError({
|
||||
"cable_end": "Must specify cable end (A or B) when attaching a cable."
|
||||
})
|
||||
if self.cable_end and not self.cable:
|
||||
raise ValidationError({
|
||||
"cable_end": "Cable end must not be set without a cable."
|
||||
})
|
||||
if self.mark_connected and self.cable:
|
||||
raise ValidationError({
|
||||
"mark_connected": "Cannot mark as connected with a cable attached."
|
||||
})
|
||||
|
||||
def get_link_peer(self):
|
||||
return self._link_peer
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
return bool(self.mark_connected or self.cable_id)
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
raise NotImplementedError("CableTermination models must implement parent_object()")
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""
|
||||
@@ -180,10 +158,31 @@ class LinkTermination(models.Model):
|
||||
"""
|
||||
return self.cable
|
||||
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
|
||||
return [peer.termination for peer in peers]
|
||||
return []
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
return bool(self.mark_connected or self.cable_id)
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
|
||||
|
||||
@property
|
||||
def opposite_cable_end(self):
|
||||
if not self.cable_end:
|
||||
return None
|
||||
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
|
||||
|
||||
|
||||
class PathEndpoint(models.Model):
|
||||
"""
|
||||
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
|
||||
An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
|
||||
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
|
||||
|
||||
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
|
||||
@@ -206,50 +205,48 @@ class PathEndpoint(models.Model):
|
||||
origin = self
|
||||
path = []
|
||||
|
||||
# Construct the complete path
|
||||
# Construct the complete path (including e.g. bridged interfaces)
|
||||
while origin is not None:
|
||||
|
||||
if origin._path is None:
|
||||
break
|
||||
|
||||
path.extend([origin, *origin._path.get_path()])
|
||||
while (len(path) + 1) % 3:
|
||||
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
|
||||
path.append(None)
|
||||
path.append(origin._path.destination)
|
||||
path.extend(origin._path.path_objects)
|
||||
|
||||
# Check for bridge interface to continue the trace
|
||||
origin = getattr(origin._path.destination, 'bridge', None)
|
||||
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
|
||||
if len(path) % 3 == 1:
|
||||
path.extend(([], []))
|
||||
# If the path ends at a site or provider network, inject a null "link" to render an attachment
|
||||
elif len(path) % 3 == 2:
|
||||
path.insert(-1, [])
|
||||
|
||||
# Return the path as a list of three-tuples (A termination, cable, B termination)
|
||||
# Check for a bridged relationship to continue the trace
|
||||
destinations = origin._path.destinations
|
||||
if len(destinations) == 1:
|
||||
origin = getattr(destinations[0], 'bridge', None)
|
||||
else:
|
||||
origin = None
|
||||
|
||||
# Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
|
||||
return list(zip(*[iter(path)] * 3))
|
||||
|
||||
def get_trace_svg(self, base_url=None, width=None):
|
||||
if width is not None:
|
||||
trace = CableTraceSVG(self, base_url=base_url, width=width)
|
||||
else:
|
||||
trace = CableTraceSVG(self, base_url=base_url)
|
||||
return trace.render()
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
@cached_property
|
||||
def connected_endpoints(self):
|
||||
"""
|
||||
Caching accessor for the attached CablePath's destination (if any)
|
||||
"""
|
||||
if not hasattr(self, '_connected_endpoint'):
|
||||
self._connected_endpoint = self._path.destination if self._path else None
|
||||
return self._connected_endpoint
|
||||
return self._path.destinations if self._path else []
|
||||
|
||||
|
||||
#
|
||||
# Console components
|
||||
#
|
||||
|
||||
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
"""
|
||||
@@ -276,7 +273,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
"""
|
||||
@@ -307,7 +304,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
# Power components
|
||||
#
|
||||
|
||||
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
"""
|
||||
@@ -348,36 +345,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
def get_downstream_powerports(self, leg=None):
|
||||
"""
|
||||
Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
|
||||
below, PP1.get_downstream_powerports() would return PP2-4.
|
||||
|
||||
---- PO1 <---> PP2
|
||||
/
|
||||
PP1 ------- PO2 <---> PP3
|
||||
\
|
||||
---- PO3 <---> PP4
|
||||
|
||||
"""
|
||||
poweroutlets = self.poweroutlets.filter(cable__isnull=False)
|
||||
if leg:
|
||||
poweroutlets = poweroutlets.filter(feed_leg=leg)
|
||||
if not poweroutlets:
|
||||
return PowerPort.objects.none()
|
||||
|
||||
q = Q()
|
||||
for poweroutlet in poweroutlets:
|
||||
q |= Q(
|
||||
cable=poweroutlet.cable,
|
||||
cable_end=poweroutlet.opposite_cable_end
|
||||
)
|
||||
|
||||
return PowerPort.objects.filter(q)
|
||||
|
||||
def get_power_draw(self):
|
||||
"""
|
||||
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
|
||||
"""
|
||||
from dcim.models import PowerFeed
|
||||
|
||||
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually
|
||||
if self.allocated_draw is None and self.maximum_draw is None:
|
||||
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(
|
||||
_link_peer_type=poweroutlet_ct,
|
||||
_link_peer_id__in=outlet_ids
|
||||
).aggregate(
|
||||
utilization = self.get_downstream_powerports().aggregate(
|
||||
maximum_draw_total=Sum('maximum_draw'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
ret = {
|
||||
'allocated': utilization['allocated_draw_total'] or 0,
|
||||
'maximum': utilization['maximum_draw_total'] or 0,
|
||||
'outlet_count': len(outlet_ids),
|
||||
'outlet_count': self.poweroutlets.count(),
|
||||
'legs': [],
|
||||
}
|
||||
|
||||
# Calculate per-leg aggregates for three-phase feeds
|
||||
if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
|
||||
# Calculate per-leg aggregates for three-phase power feeds
|
||||
if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
|
||||
self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
|
||||
for leg, leg_name in PowerOutletFeedLegChoices:
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(
|
||||
_link_peer_type=poweroutlet_ct,
|
||||
_link_peer_id__in=outlet_ids
|
||||
).aggregate(
|
||||
utilization = self.get_downstream_powerports(leg=leg).aggregate(
|
||||
maximum_draw_total=Sum('maximum_draw'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
@@ -385,7 +403,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
'name': leg_name,
|
||||
'allocated': utilization['allocated_draw_total'] or 0,
|
||||
'maximum': utilization['maximum_draw_total'] or 0,
|
||||
'outlet_count': len(outlet_ids),
|
||||
'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
|
||||
})
|
||||
|
||||
return ret
|
||||
@@ -394,12 +412,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
return {
|
||||
'allocated': self.allocated_draw or 0,
|
||||
'maximum': self.maximum_draw or 0,
|
||||
'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
|
||||
'outlet_count': self.poweroutlets.count(),
|
||||
'legs': [],
|
||||
}
|
||||
|
||||
|
||||
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
"""
|
||||
@@ -437,9 +455,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device != self.device:
|
||||
raise ValidationError(
|
||||
"Parent power port ({}) must belong to the same device".format(self.power_port)
|
||||
)
|
||||
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
|
||||
|
||||
|
||||
#
|
||||
@@ -513,7 +529,7 @@ class BaseInterface(models.Model):
|
||||
return self.fhrp_group_assignments.count()
|
||||
|
||||
|
||||
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
|
||||
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
|
||||
"""
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
"""
|
||||
@@ -543,7 +559,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
)
|
||||
speed = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
verbose_name='Speed (Kbps)'
|
||||
)
|
||||
duplex = models.CharField(
|
||||
max_length=50,
|
||||
@@ -589,6 +606,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
validators=(MaxValueValidator(127),),
|
||||
verbose_name='Transmit power (dBm)'
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoEModeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE mode'
|
||||
)
|
||||
poe_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoETypeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE type'
|
||||
)
|
||||
wireless_link = models.ForeignKey(
|
||||
to='wireless.WirelessLink',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -636,8 +665,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
object_id_field='interface_id',
|
||||
related_query_name='+'
|
||||
)
|
||||
l2vpn_terminations = GenericRelation(
|
||||
to='ipam.L2VPNTermination',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface',
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
|
||||
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
@@ -725,6 +760,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
f"of virtual chassis {self.device.virtual_chassis}."
|
||||
})
|
||||
|
||||
# PoE validation
|
||||
|
||||
# Only physical interfaces may have a PoE mode/type assigned
|
||||
if self.poe_mode and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_mode': "Virtual interfaces cannot have a PoE mode."
|
||||
})
|
||||
if self.poe_type and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_type': "Virtual interfaces cannot have a PoE type."
|
||||
})
|
||||
|
||||
# An interface with a PoE type set must also specify a mode
|
||||
if self.poe_type and not self.poe_mode:
|
||||
raise ValidationError({
|
||||
'poe_type': "Must specify PoE mode when designating a PoE type."
|
||||
})
|
||||
|
||||
# Wireless validation
|
||||
|
||||
# RF role & channel may only be set for wireless interfaces
|
||||
@@ -792,12 +845,28 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
def link(self):
|
||||
return self.cable or self.wireless_link
|
||||
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
return super().link_peers
|
||||
if self.wireless_link:
|
||||
# Return the opposite side of the attached wireless link
|
||||
if self.wireless_link.interface_a == self:
|
||||
return [self.wireless_link.interface_b]
|
||||
else:
|
||||
return [self.wireless_link.interface_a]
|
||||
return []
|
||||
|
||||
@property
|
||||
def l2vpn_termination(self):
|
||||
return self.l2vpn_terminations.first()
|
||||
|
||||
|
||||
#
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
class FrontPort(ModularComponentModel, LinkTermination):
|
||||
class FrontPort(ModularComponentModel, CabledObjectModel):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
"""
|
||||
@@ -850,7 +919,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
|
||||
})
|
||||
|
||||
|
||||
class RearPort(ModularComponentModel, LinkTermination):
|
||||
class RearPort(ModularComponentModel, CabledObjectModel):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
import decimal
|
||||
|
||||
import yaml
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
@@ -99,8 +99,10 @@ class DeviceType(NetBoxModel):
|
||||
blank=True,
|
||||
help_text='Discrete part number (optional)'
|
||||
)
|
||||
u_height = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
u_height = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
default=1.0,
|
||||
verbose_name='Height (U)'
|
||||
)
|
||||
is_full_depth = models.BooleanField(
|
||||
@@ -161,115 +163,54 @@ class DeviceType(NetBoxModel):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
def to_yaml(self):
|
||||
data = OrderedDict((
|
||||
('manufacturer', self.manufacturer.name),
|
||||
('model', self.model),
|
||||
('slug', self.slug),
|
||||
('part_number', self.part_number),
|
||||
('u_height', self.u_height),
|
||||
('is_full_depth', self.is_full_depth),
|
||||
('subdevice_role', self.subdevice_role),
|
||||
('airflow', self.airflow),
|
||||
('comments', self.comments),
|
||||
))
|
||||
data = {
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'slug': self.slug,
|
||||
'part_number': self.part_number,
|
||||
'u_height': float(self.u_height),
|
||||
'is_full_depth': self.is_full_depth,
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
if self.consoleporttemplates.exists():
|
||||
data['console-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleporttemplates.all()
|
||||
]
|
||||
if self.consoleserverporttemplates.exists():
|
||||
data['console-server-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleserverporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleserverporttemplates.all()
|
||||
]
|
||||
if self.powerporttemplates.exists():
|
||||
data['power-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'maximum_draw': c.maximum_draw,
|
||||
'allocated_draw': c.allocated_draw,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.powerporttemplates.all()
|
||||
c.to_yaml() for c in self.powerporttemplates.all()
|
||||
]
|
||||
if self.poweroutlettemplates.exists():
|
||||
data['power-outlets'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'power_port': c.power_port.name if c.power_port else None,
|
||||
'feed_leg': c.feed_leg,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.poweroutlettemplates.all()
|
||||
c.to_yaml() for c in self.poweroutlettemplates.all()
|
||||
]
|
||||
if self.interfacetemplates.exists():
|
||||
data['interfaces'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'mgmt_only': c.mgmt_only,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.interfacetemplates.all()
|
||||
c.to_yaml() for c in self.interfacetemplates.all()
|
||||
]
|
||||
if self.frontporttemplates.exists():
|
||||
data['front-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'rear_port': c.rear_port.name,
|
||||
'rear_port_position': c.rear_port_position,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.frontporttemplates.all()
|
||||
c.to_yaml() for c in self.frontporttemplates.all()
|
||||
]
|
||||
if self.rearporttemplates.exists():
|
||||
data['rear-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'positions': c.positions,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.rearporttemplates.all()
|
||||
c.to_yaml() for c in self.rearporttemplates.all()
|
||||
]
|
||||
if self.modulebaytemplates.exists():
|
||||
data['module-bays'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'label': c.label,
|
||||
'position': c.position,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.modulebaytemplates.all()
|
||||
c.to_yaml() for c in self.modulebaytemplates.all()
|
||||
]
|
||||
if self.devicebaytemplates.exists():
|
||||
data['device-bays'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.devicebaytemplates.all()
|
||||
c.to_yaml() for c in self.devicebaytemplates.all()
|
||||
]
|
||||
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
@@ -277,6 +218,12 @@ class DeviceType(NetBoxModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# U height must be divisible by 0.5
|
||||
if self.u_height % decimal.Decimal(0.5):
|
||||
raise ValidationError({
|
||||
'u_height': "U height must be in increments of 0.5 rack units."
|
||||
})
|
||||
|
||||
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
|
||||
# room to expand within their racks. This validation will impose a very high performance penalty when there are
|
||||
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
|
||||
@@ -395,91 +342,41 @@ class ModuleType(NetBoxModel):
|
||||
return reverse('dcim:moduletype', args=[self.pk])
|
||||
|
||||
def to_yaml(self):
|
||||
data = OrderedDict((
|
||||
('manufacturer', self.manufacturer.name),
|
||||
('model', self.model),
|
||||
('part_number', self.part_number),
|
||||
('comments', self.comments),
|
||||
))
|
||||
data = {
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
if self.consoleporttemplates.exists():
|
||||
data['console-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleporttemplates.all()
|
||||
]
|
||||
if self.consoleserverporttemplates.exists():
|
||||
data['console-server-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleserverporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleserverporttemplates.all()
|
||||
]
|
||||
if self.powerporttemplates.exists():
|
||||
data['power-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'maximum_draw': c.maximum_draw,
|
||||
'allocated_draw': c.allocated_draw,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.powerporttemplates.all()
|
||||
c.to_yaml() for c in self.powerporttemplates.all()
|
||||
]
|
||||
if self.poweroutlettemplates.exists():
|
||||
data['power-outlets'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'power_port': c.power_port.name if c.power_port else None,
|
||||
'feed_leg': c.feed_leg,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.poweroutlettemplates.all()
|
||||
c.to_yaml() for c in self.poweroutlettemplates.all()
|
||||
]
|
||||
if self.interfacetemplates.exists():
|
||||
data['interfaces'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'mgmt_only': c.mgmt_only,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.interfacetemplates.all()
|
||||
c.to_yaml() for c in self.interfacetemplates.all()
|
||||
]
|
||||
if self.frontporttemplates.exists():
|
||||
data['front-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'rear_port': c.rear_port.name,
|
||||
'rear_port_position': c.rear_port_position,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.frontporttemplates.all()
|
||||
c.to_yaml() for c in self.frontporttemplates.all()
|
||||
]
|
||||
if self.rearporttemplates.exists():
|
||||
data['rear-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'positions': c.positions,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.rearporttemplates.all()
|
||||
c.to_yaml() for c in self.rearporttemplates.all()
|
||||
]
|
||||
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
@@ -654,10 +551,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
position = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
|
||||
verbose_name='Position (U)',
|
||||
help_text='The lowest-numbered unit occupied by the device'
|
||||
)
|
||||
@@ -748,8 +647,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.asset_tag:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
|
||||
return super().__str__()
|
||||
@@ -803,7 +706,11 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
'position': "Cannot select a rack position without assigning a rack.",
|
||||
})
|
||||
|
||||
# Validate position/face combination
|
||||
# Validate rack position and face
|
||||
if self.position and self.position % decimal.Decimal(0.5):
|
||||
raise ValidationError({
|
||||
'position': "Position must be in increments of 0.5 rack units."
|
||||
})
|
||||
if self.position and not self.face:
|
||||
raise ValidationError({
|
||||
'face': "Must specify rack face when defining rack position.",
|
||||
@@ -1065,30 +972,52 @@ class Module(NetBoxModel, ConfigContextModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If this is a new Module and component replication has not been disabled, instantiate all its
|
||||
# related components per the ModuleType definition
|
||||
if is_new and not getattr(self, '_disable_replication', False):
|
||||
ConsolePort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
|
||||
)
|
||||
PowerPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()]
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()]
|
||||
)
|
||||
Interface.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()]
|
||||
)
|
||||
RearPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()]
|
||||
)
|
||||
FrontPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()]
|
||||
)
|
||||
adopt_components = getattr(self, '_adopt_components', False)
|
||||
disable_replication = getattr(self, '_disable_replication', False)
|
||||
|
||||
# We skip adding components if the module is being edited or
|
||||
# both replication and component adoption is disabled
|
||||
if not is_new or (disable_replication and not adopt_components):
|
||||
return
|
||||
|
||||
# Iterate all component types
|
||||
for templates, component_attribute, component_model in [
|
||||
("consoleporttemplates", "consoleports", ConsolePort),
|
||||
("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
|
||||
("interfacetemplates", "interfaces", Interface),
|
||||
("powerporttemplates", "powerports", PowerPort),
|
||||
("poweroutlettemplates", "poweroutlets", PowerOutlet),
|
||||
("rearporttemplates", "rearports", RearPort),
|
||||
("frontporttemplates", "frontports", FrontPort)
|
||||
]:
|
||||
create_instances = []
|
||||
update_instances = []
|
||||
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
|
||||
}
|
||||
|
||||
# Get the template for the module type.
|
||||
for template in getattr(self.module_type, templates).all():
|
||||
template_instance = template.instantiate(device=self.device, module=self)
|
||||
|
||||
if adopt_components:
|
||||
existing_item = installed_components.get(template_instance.name)
|
||||
|
||||
# Check if there's a component with the same name already
|
||||
if existing_item:
|
||||
# Assign it to the module
|
||||
existing_item.module = self
|
||||
update_instances.append(existing_item)
|
||||
continue
|
||||
|
||||
# Only create new components if replication is enabled
|
||||
if not disable_replication:
|
||||
create_instances.append(template_instance)
|
||||
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
component_model.objects.bulk_update(update_instances, ['module'])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -6,9 +6,10 @@ from django.urls import reverse
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import NetBoxModel
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import LinkTermination, PathEndpoint
|
||||
from .device_components import CabledObjectModel, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'PowerFeed',
|
||||
@@ -66,7 +67,7 @@ class PowerPanel(NetBoxModel):
|
||||
)
|
||||
|
||||
|
||||
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
||||
class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
@@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||
)
|
||||
voltage = models.SmallIntegerField(
|
||||
default=POWERFEED_VOLTAGE_DEFAULT,
|
||||
default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
|
||||
validators=[ExclusionValidator([0])]
|
||||
)
|
||||
amperage = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1)],
|
||||
default=POWERFEED_AMPERAGE_DEFAULT
|
||||
default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
|
||||
)
|
||||
max_utilization = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
|
||||
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
|
||||
help_text="Maximum permissible draw (percentage)"
|
||||
)
|
||||
available_power = models.PositiveIntegerField(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
import decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
@@ -13,11 +13,10 @@ from django.urls import reverse
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.utils import array_to_string
|
||||
from utilities.utils import array_to_string, drange
|
||||
from .device_components import PowerOutlet, PowerPort
|
||||
from .devices import Device
|
||||
from .power import PowerFeed
|
||||
@@ -242,10 +241,12 @@ class Rack(NetBoxModel):
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
"""
|
||||
Return a list of unit numbers, top to bottom.
|
||||
"""
|
||||
if self.desc_units:
|
||||
return range(1, self.u_height + 1)
|
||||
else:
|
||||
return reversed(range(1, self.u_height + 1))
|
||||
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
|
||||
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
|
||||
|
||||
def get_status_color(self):
|
||||
return RackStatusChoices.colors.get(self.status)
|
||||
@@ -263,12 +264,12 @@ class Rack(NetBoxModel):
|
||||
reference to the device. When False, only the bottom most unit for a device is included and that unit
|
||||
contains a height attribute for the device
|
||||
"""
|
||||
|
||||
elevation = OrderedDict()
|
||||
elevation = {}
|
||||
for u in self.units:
|
||||
u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
|
||||
elevation[u] = {
|
||||
'id': u,
|
||||
'name': f'U{u}',
|
||||
'name': u_name,
|
||||
'face': face,
|
||||
'device': None,
|
||||
'occupied': False
|
||||
@@ -278,7 +279,7 @@ class Rack(NetBoxModel):
|
||||
if self.pk:
|
||||
|
||||
# Retrieve all devices installed within the rack
|
||||
queryset = Device.objects.prefetch_related(
|
||||
devices = Device.objects.prefetch_related(
|
||||
'device_type',
|
||||
'device_type__manufacturer',
|
||||
'device_role'
|
||||
@@ -299,9 +300,9 @@ class Rack(NetBoxModel):
|
||||
if user is not None:
|
||||
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
|
||||
|
||||
for device in queryset:
|
||||
for device in devices:
|
||||
if expand_devices:
|
||||
for u in range(device.position, device.position + device.device_type.u_height):
|
||||
for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
|
||||
if user is None or device.pk in permitted_device_ids:
|
||||
elevation[u]['device'] = device
|
||||
elevation[u]['occupied'] = True
|
||||
@@ -310,8 +311,6 @@ class Rack(NetBoxModel):
|
||||
elevation[device.position]['device'] = device
|
||||
elevation[device.position]['occupied'] = True
|
||||
elevation[device.position]['height'] = device.device_type.u_height
|
||||
for u in range(device.position + 1, device.position + device.device_type.u_height):
|
||||
elevation.pop(u, None)
|
||||
|
||||
return [u for u in elevation.values()]
|
||||
|
||||
@@ -331,12 +330,12 @@ class Rack(NetBoxModel):
|
||||
devices = devices.exclude(pk__in=exclude)
|
||||
|
||||
# Initialize the rack unit skeleton
|
||||
units = list(range(1, self.u_height + 1))
|
||||
units = list(self.units)
|
||||
|
||||
# Remove units consumed by installed devices
|
||||
for d in devices:
|
||||
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
|
||||
for u in range(d.position, d.position + d.device_type.u_height):
|
||||
for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
|
||||
try:
|
||||
units.remove(u)
|
||||
except ValueError:
|
||||
@@ -346,7 +345,7 @@ class Rack(NetBoxModel):
|
||||
# Remove units without enough space above them to accommodate a device of the specified height
|
||||
available_units = []
|
||||
for u in units:
|
||||
if set(range(u, u + u_height)).issubset(units):
|
||||
if set(drange(u, u + u_height, 0.5)).issubset(units):
|
||||
available_units.append(u)
|
||||
|
||||
return list(reversed(available_units))
|
||||
@@ -356,9 +355,9 @@ class Rack(NetBoxModel):
|
||||
Return a dictionary mapping all reserved units within the rack to their reservation.
|
||||
"""
|
||||
reserved_units = {}
|
||||
for r in self.reservations.all():
|
||||
for u in r.units:
|
||||
reserved_units[u] = r
|
||||
for reservation in self.reservations.all():
|
||||
for u in reservation.units:
|
||||
reserved_units[u] = reservation
|
||||
return reserved_units
|
||||
|
||||
def get_elevation_svg(
|
||||
@@ -367,9 +366,11 @@ class Rack(NetBoxModel):
|
||||
user=None,
|
||||
unit_width=None,
|
||||
unit_height=None,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
|
||||
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
|
||||
include_images=True,
|
||||
base_url=None
|
||||
base_url=None,
|
||||
highlight_params=None
|
||||
):
|
||||
"""
|
||||
Return an SVG of the rack elevation
|
||||
@@ -381,16 +382,23 @@ class Rack(NetBoxModel):
|
||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||
height of the elevation
|
||||
:param legend_width: Width of the unit legend, in pixels
|
||||
:param margin_width: Width of the rigth-hand margin, in pixels
|
||||
:param include_images: Embed front/rear device images where available
|
||||
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
||||
"""
|
||||
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
|
||||
if unit_width is None or unit_height is None:
|
||||
config = get_config()
|
||||
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
||||
elevation = RackElevationSVG(
|
||||
self,
|
||||
unit_width=unit_width,
|
||||
unit_height=unit_height,
|
||||
legend_width=legend_width,
|
||||
margin_width=margin_width,
|
||||
user=user,
|
||||
include_images=include_images,
|
||||
base_url=base_url,
|
||||
highlight_params=highlight_params
|
||||
)
|
||||
|
||||
return elevation.render(face, unit_width, unit_height, legend_width)
|
||||
return elevation.render(face)
|
||||
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
@@ -401,6 +409,7 @@ class Rack(NetBoxModel):
|
||||
as utilized.
|
||||
"""
|
||||
# Determine unoccupied units
|
||||
total_units = len(list(self.units))
|
||||
available_units = self.get_available_units()
|
||||
|
||||
# Remove reserved units
|
||||
@@ -408,8 +417,8 @@ class Rack(NetBoxModel):
|
||||
if u in available_units:
|
||||
available_units.remove(u)
|
||||
|
||||
occupied_unit_count = self.u_height - len(available_units)
|
||||
percentage = float(occupied_unit_count) / self.u_height * 100
|
||||
occupied_unit_count = total_units - len(available_units)
|
||||
percentage = float(occupied_unit_count) / total_units * 100
|
||||
|
||||
return percentage
|
||||
|
||||
@@ -422,17 +431,17 @@ class Rack(NetBoxModel):
|
||||
if not available_power_total:
|
||||
return 0
|
||||
|
||||
pf_powerports = PowerPort.objects.filter(
|
||||
_link_peer_type=ContentType.objects.get_for_model(PowerFeed),
|
||||
_link_peer_id__in=powerfeeds.values_list('id', flat=True)
|
||||
)
|
||||
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
|
||||
allocated_draw_total = PowerPort.objects.filter(
|
||||
_link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
|
||||
_link_peer_id__in=poweroutlets.values_list('id', flat=True)
|
||||
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
|
||||
powerports = []
|
||||
for powerfeed in powerfeeds:
|
||||
powerports.extend([
|
||||
peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
|
||||
])
|
||||
|
||||
return int(allocated_draw_total / available_power_total * 100)
|
||||
allocated_draw = sum([
|
||||
powerport.get_power_draw()['allocated'] for powerport in powerports
|
||||
])
|
||||
|
||||
return int(allocated_draw / available_power_total * 100)
|
||||
|
||||
|
||||
class RackReservation(NetBoxModel):
|
||||
|
||||
@@ -341,6 +341,11 @@ class Location(NestedGroupModel):
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=LocationStatusChoices,
|
||||
default=LocationStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -367,7 +372,7 @@ class Location(NestedGroupModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ['site', 'parent', 'tenant', 'description']
|
||||
clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -409,6 +414,9 @@ class Location(NestedGroupModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return LocationStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import LinkStatusChoices
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
|
||||
@@ -68,73 +68,58 @@ def clear_virtualchassis_members(instance, **kwargs):
|
||||
# Cables
|
||||
#
|
||||
|
||||
|
||||
@receiver(post_save, sender=Cable)
|
||||
@receiver(trace_paths, sender=Cable)
|
||||
def update_connected_endpoints(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
When a Cable is saved, check for and update its two connected endpoints
|
||||
When a Cable is saved with new terminations, retrace any affected cable paths.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
if raw:
|
||||
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
|
||||
return
|
||||
|
||||
# Cache the Cable on its two termination points
|
||||
if instance.termination_a.cable != instance:
|
||||
logger.debug(f"Updating termination A for cable {instance}")
|
||||
instance.termination_a.cable = instance
|
||||
instance.termination_a._link_peer = instance.termination_b
|
||||
instance.termination_a.save()
|
||||
if instance.termination_b.cable != instance:
|
||||
logger.debug(f"Updating termination B for cable {instance}")
|
||||
instance.termination_b.cable = instance
|
||||
instance.termination_b._link_peer = instance.termination_a
|
||||
instance.termination_b.save()
|
||||
|
||||
# Create/update cable paths
|
||||
if created:
|
||||
for termination in (instance.termination_a, instance.termination_b):
|
||||
if isinstance(termination, PathEndpoint):
|
||||
create_cablepath(termination)
|
||||
# Update cable paths if new terminations have been set
|
||||
if instance._terminations_modified:
|
||||
a_terminations = []
|
||||
b_terminations = []
|
||||
for t in instance.terminations.all():
|
||||
if t.cable_end == CableEndChoices.SIDE_A:
|
||||
a_terminations.append(t.termination)
|
||||
else:
|
||||
rebuild_paths(termination)
|
||||
b_terminations.append(t.termination)
|
||||
for nodes in [a_terminations, b_terminations]:
|
||||
# Examine type of first termination to determine object type (all must be the same)
|
||||
if not nodes:
|
||||
continue
|
||||
if isinstance(nodes[0], PathEndpoint):
|
||||
create_cablepath(nodes)
|
||||
else:
|
||||
rebuild_paths(nodes)
|
||||
|
||||
# Update status of CablePaths if Cable status has been changed
|
||||
elif instance.status != instance._orig_status:
|
||||
# We currently don't support modifying either termination of an existing Cable. (This
|
||||
# may change in the future.) However, we do need to capture status changes and update
|
||||
# any CablePaths accordingly.
|
||||
if instance.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
CablePath.objects.filter(path__contains=instance).update(is_active=False)
|
||||
CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
|
||||
else:
|
||||
rebuild_paths(instance)
|
||||
rebuild_paths([instance])
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Cable)
|
||||
def retrace_cable_paths(instance, **kwargs):
|
||||
"""
|
||||
When a Cable is deleted, check for and update its connected endpoints
|
||||
"""
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
|
||||
cablepath.retrace()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CableTermination)
|
||||
def nullify_connected_endpoints(instance, **kwargs):
|
||||
"""
|
||||
When a Cable is deleted, check for and update its two connected endpoints
|
||||
Disassociate the Cable from the termination object, and retrace any affected CablePaths.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
model = instance.termination_type.model_class()
|
||||
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
|
||||
|
||||
# Disassociate the Cable from its termination points
|
||||
if instance.termination_a is not None:
|
||||
logger.debug(f"Nullifying termination A for cable {instance}")
|
||||
model = instance.termination_a._meta.model
|
||||
model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
|
||||
if instance.termination_b is not None:
|
||||
logger.debug(f"Nullifying termination B for cable {instance}")
|
||||
model = instance.termination_b._meta.model
|
||||
model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
|
||||
|
||||
# Delete and retrace any dependent cable paths
|
||||
for cablepath in CablePath.objects.filter(path__contains=instance):
|
||||
cp = CablePath.from_origin(cablepath.origin)
|
||||
if cp:
|
||||
CablePath.objects.filter(pk=cablepath.pk).update(
|
||||
path=cp.path,
|
||||
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
|
||||
destination_id=cp.destination.pk if cp.destination else None,
|
||||
is_active=cp.is_active,
|
||||
is_split=cp.is_split
|
||||
)
|
||||
else:
|
||||
cablepath.delete()
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
cablepath.retrace()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user