mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-03 07:29:31 +01:00
Compare commits
180 Commits
v3.6-beta1
...
v3.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952be24365 | ||
|
|
b57a47475d | ||
|
|
4f05cf55a5 | ||
|
|
5dcf8502af | ||
|
|
7a21541ed6 | ||
|
|
ae4ea3443e | ||
|
|
f5dd7d853a | ||
|
|
a1e42dad10 | ||
|
|
6e4b4a553b | ||
|
|
7a410dfd00 | ||
|
|
6fb980349f | ||
|
|
8e251ac33c | ||
|
|
35bcc2ce9d | ||
|
|
69215c411b | ||
|
|
a08b5793f6 | ||
|
|
252bf03525 | ||
|
|
b9b9bb134f | ||
|
|
68966db23d | ||
|
|
9aa7444bf9 | ||
|
|
b0541be107 | ||
|
|
3d1f668235 | ||
|
|
940c947d3f | ||
|
|
c7dd4206c8 | ||
|
|
79bf12a8fe | ||
|
|
2dfbd72f10 | ||
|
|
487827c776 | ||
|
|
6939bf8aed | ||
|
|
e4cb0c3cc2 | ||
|
|
cf2f39a0a8 | ||
|
|
b7cfb2f7d9 | ||
|
|
39cb9c32d6 | ||
|
|
75b71890a4 | ||
|
|
2ffa6d0188 | ||
|
|
026386db50 | ||
|
|
b5125e512f | ||
|
|
a8a36c0a8f | ||
|
|
99ab054ea0 | ||
|
|
90ab4b3c86 | ||
|
|
bb6b4d01c1 | ||
|
|
2d1457b94b | ||
|
|
9d851924c8 | ||
|
|
9be5918c83 | ||
|
|
6db6616892 | ||
|
|
004daca862 | ||
|
|
559f65f6b2 | ||
|
|
c38884fa11 | ||
|
|
7848beedce | ||
|
|
296166da95 | ||
|
|
679cc8fdda | ||
|
|
0cdc26e013 | ||
|
|
2503568875 | ||
|
|
78966e12a9 | ||
|
|
f962fb3b53 | ||
|
|
2544e2bf18 | ||
|
|
06f2c6f867 | ||
|
|
272d2c54d4 | ||
|
|
cb93abb0f4 | ||
|
|
316d991b33 | ||
|
|
46f734eba2 | ||
|
|
671a56100a | ||
|
|
dfcfbe240d | ||
|
|
b040fdcf2c | ||
|
|
8525f994c0 | ||
|
|
eb9a804914 | ||
|
|
210d7bb573 | ||
|
|
dc85476b9e | ||
|
|
1854a6b76b | ||
|
|
aebf3288d1 | ||
|
|
065a40dfb3 | ||
|
|
83536fbb23 | ||
|
|
420090dc6c | ||
|
|
4ab0eb570c | ||
|
|
2a4e3dd09f | ||
|
|
0dbfbf6941 | ||
|
|
d515530277 | ||
|
|
4343e0566b | ||
|
|
8555269f7e | ||
|
|
f42a2ac10c | ||
|
|
4ea3a29c0e | ||
|
|
29877c9abe | ||
|
|
480f83c42d | ||
|
|
faf89350ac | ||
|
|
d9c3ce935f | ||
|
|
8d8f57e8b8 | ||
|
|
0a3be0b7ea | ||
|
|
00ebdfe0df | ||
|
|
d79fa131bb | ||
|
|
be2b24a155 | ||
|
|
03b341dbfd | ||
|
|
ca5e69897d | ||
|
|
3090dd4934 | ||
|
|
1f1d1ee502 | ||
|
|
1c2cf11f47 | ||
|
|
08961e751d | ||
|
|
88bf82be05 | ||
|
|
506884bc4d | ||
|
|
646fa341ab | ||
|
|
d73f7b1943 | ||
|
|
a75e8416a4 | ||
|
|
f743f2cfb8 | ||
|
|
7d7e8127f5 | ||
|
|
3c0a3ca703 | ||
|
|
45062697c5 | ||
|
|
66e4e31209 | ||
|
|
c86cfe3cbf | ||
|
|
28e112743f | ||
|
|
229007082b | ||
|
|
4004966b16 | ||
|
|
fe95cb434a | ||
|
|
5709bc3b2b | ||
|
|
af06510921 | ||
|
|
b4acbb5e16 | ||
|
|
b96e437e2b | ||
|
|
16e2283d19 | ||
|
|
c46536f469 | ||
|
|
0457520f51 | ||
|
|
44f8a777df | ||
|
|
9450ce4c3a | ||
|
|
1c9a8ec6bd | ||
|
|
8f5005efd5 | ||
|
|
e61795d5c6 | ||
|
|
892c10b1f0 | ||
|
|
752e26c7de | ||
|
|
ea107b6b86 | ||
|
|
b9b9c065cc | ||
|
|
b583770765 | ||
|
|
37d6f6abca | ||
|
|
be3f48c677 | ||
|
|
5de9d3f15f | ||
|
|
8593715149 | ||
|
|
40afe6cf36 | ||
|
|
9fd07b594c | ||
|
|
dc7411e4c5 | ||
|
|
315c4bb1ac | ||
|
|
1ff1b4dc89 | ||
|
|
a332adf962 | ||
|
|
856cc0f885 | ||
|
|
89d8f7aa70 | ||
|
|
4d2ef0a8b5 | ||
|
|
23b3f72dee | ||
|
|
ff59845821 | ||
|
|
914588f55d | ||
|
|
72e1e8fab1 | ||
|
|
8b01c30c51 | ||
|
|
dcdb4d27ec | ||
|
|
9b1406a1a7 | ||
|
|
545769ad88 | ||
|
|
16bcb1dbb0 | ||
|
|
5dce5563ab | ||
|
|
4e8a3e0a6f | ||
|
|
646d52d498 | ||
|
|
cd5012bd59 | ||
|
|
4bb0388118 | ||
|
|
f255fe507d | ||
|
|
f5a1f83f9f | ||
|
|
36072f17a9 | ||
|
|
f9648d8544 | ||
|
|
2236b86c35 | ||
|
|
0dd319d0c8 | ||
|
|
53615944c5 | ||
|
|
88562d7dcf | ||
|
|
01bb09db67 | ||
|
|
f1c182bb65 | ||
|
|
43ce453938 | ||
|
|
2afce6c94b | ||
|
|
14e23c3d00 | ||
|
|
7f22c6bf12 | ||
|
|
93a862cded | ||
|
|
9cc295827b | ||
|
|
14988fc91c | ||
|
|
31f41855f4 | ||
|
|
caedc8dbe3 | ||
|
|
24ffaf09d4 | ||
|
|
d9f3637e25 | ||
|
|
a807cca29e | ||
|
|
57860f26b7 | ||
|
|
ab916a1819 | ||
|
|
a68831d3a1 | ||
|
|
04a2543e68 | ||
|
|
a4c9cbc6dd |
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.5.7
|
||||
placeholder: v3.6.2
|
||||
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.5.7
|
||||
placeholder: v3.6.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
</div>
|
||||
<h3></h3>
|
||||
|
||||
Some general tips for engaging here on GitHub:
|
||||
## :information_source: Welcome to the Stadium!
|
||||
|
||||
In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well:
|
||||
|
||||
> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers.
|
||||
|
||||
The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users.
|
||||
|
||||
If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them.
|
||||
|
||||
NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others.
|
||||
|
||||
### General Tips for Working on GitHub
|
||||
|
||||
* Register for a free [GitHub account](https://github.com/signup) if you haven't already.
|
||||
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
|
||||
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
|
||||
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
|
||||
* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
|
||||
|
||||
## :bug: Reporting Bugs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<p>The premier source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
564
contrib/generated_schema.json
Normal file
564
contrib/generated_schema.json
Normal file
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"airflow": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"front-to-rear",
|
||||
"rear-to-front",
|
||||
"left-to-right",
|
||||
"right-to-left",
|
||||
"side-to-rear",
|
||||
"passive",
|
||||
"mixed"
|
||||
]
|
||||
},
|
||||
"weight-unit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"kg",
|
||||
"g",
|
||||
"lb",
|
||||
"oz"
|
||||
]
|
||||
},
|
||||
"subdevice-role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"parent",
|
||||
"child"
|
||||
]
|
||||
},
|
||||
"console-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"de-9",
|
||||
"db-25",
|
||||
"rj-11",
|
||||
"rj-12",
|
||||
"rj-45",
|
||||
"mini-din-8",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"console-server-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"de-9",
|
||||
"db-25",
|
||||
"rj-11",
|
||||
"rj-12",
|
||||
"rj-45",
|
||||
"mini-din-8",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"power-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iec-60320-c6",
|
||||
"iec-60320-c8",
|
||||
"iec-60320-c14",
|
||||
"iec-60320-c16",
|
||||
"iec-60320-c20",
|
||||
"iec-60320-c22",
|
||||
"iec-60309-p-n-e-4h",
|
||||
"iec-60309-p-n-e-6h",
|
||||
"iec-60309-p-n-e-9h",
|
||||
"iec-60309-2p-e-4h",
|
||||
"iec-60309-2p-e-6h",
|
||||
"iec-60309-2p-e-9h",
|
||||
"iec-60309-3p-e-4h",
|
||||
"iec-60309-3p-e-6h",
|
||||
"iec-60309-3p-e-9h",
|
||||
"iec-60309-3p-n-e-4h",
|
||||
"iec-60309-3p-n-e-6h",
|
||||
"iec-60309-3p-n-e-9h",
|
||||
"iec-60906-1",
|
||||
"nbr-14136-10a",
|
||||
"nbr-14136-20a",
|
||||
"nema-1-15p",
|
||||
"nema-5-15p",
|
||||
"nema-5-20p",
|
||||
"nema-5-30p",
|
||||
"nema-5-50p",
|
||||
"nema-6-15p",
|
||||
"nema-6-20p",
|
||||
"nema-6-30p",
|
||||
"nema-6-50p",
|
||||
"nema-10-30p",
|
||||
"nema-10-50p",
|
||||
"nema-14-20p",
|
||||
"nema-14-30p",
|
||||
"nema-14-50p",
|
||||
"nema-14-60p",
|
||||
"nema-15-15p",
|
||||
"nema-15-20p",
|
||||
"nema-15-30p",
|
||||
"nema-15-50p",
|
||||
"nema-15-60p",
|
||||
"nema-l1-15p",
|
||||
"nema-l5-15p",
|
||||
"nema-l5-20p",
|
||||
"nema-l5-30p",
|
||||
"nema-l5-50p",
|
||||
"nema-l6-15p",
|
||||
"nema-l6-20p",
|
||||
"nema-l6-30p",
|
||||
"nema-l6-50p",
|
||||
"nema-l10-30p",
|
||||
"nema-l14-20p",
|
||||
"nema-l14-30p",
|
||||
"nema-l14-50p",
|
||||
"nema-l14-60p",
|
||||
"nema-l15-20p",
|
||||
"nema-l15-30p",
|
||||
"nema-l15-50p",
|
||||
"nema-l15-60p",
|
||||
"nema-l21-20p",
|
||||
"nema-l21-30p",
|
||||
"nema-l22-30p",
|
||||
"cs6361c",
|
||||
"cs6365c",
|
||||
"cs8165c",
|
||||
"cs8265c",
|
||||
"cs8365c",
|
||||
"cs8465c",
|
||||
"ita-c",
|
||||
"ita-e",
|
||||
"ita-f",
|
||||
"ita-ef",
|
||||
"ita-g",
|
||||
"ita-h",
|
||||
"ita-i",
|
||||
"ita-j",
|
||||
"ita-k",
|
||||
"ita-l",
|
||||
"ita-m",
|
||||
"ita-n",
|
||||
"ita-o",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"usb-3-b",
|
||||
"usb-3-micro-b",
|
||||
"dc-terminal",
|
||||
"saf-d-grid",
|
||||
"neutrik-powercon-20",
|
||||
"neutrik-powercon-32",
|
||||
"neutrik-powercon-true1",
|
||||
"neutrik-powercon-true1-top",
|
||||
"ubiquiti-smartpower",
|
||||
"hardwired",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"power-outlet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iec-60320-c5",
|
||||
"iec-60320-c7",
|
||||
"iec-60320-c13",
|
||||
"iec-60320-c15",
|
||||
"iec-60320-c19",
|
||||
"iec-60320-c21",
|
||||
"iec-60309-p-n-e-4h",
|
||||
"iec-60309-p-n-e-6h",
|
||||
"iec-60309-p-n-e-9h",
|
||||
"iec-60309-2p-e-4h",
|
||||
"iec-60309-2p-e-6h",
|
||||
"iec-60309-2p-e-9h",
|
||||
"iec-60309-3p-e-4h",
|
||||
"iec-60309-3p-e-6h",
|
||||
"iec-60309-3p-e-9h",
|
||||
"iec-60309-3p-n-e-4h",
|
||||
"iec-60309-3p-n-e-6h",
|
||||
"iec-60309-3p-n-e-9h",
|
||||
"iec-60906-1",
|
||||
"nbr-14136-10a",
|
||||
"nbr-14136-20a",
|
||||
"nema-1-15r",
|
||||
"nema-5-15r",
|
||||
"nema-5-20r",
|
||||
"nema-5-30r",
|
||||
"nema-5-50r",
|
||||
"nema-6-15r",
|
||||
"nema-6-20r",
|
||||
"nema-6-30r",
|
||||
"nema-6-50r",
|
||||
"nema-10-30r",
|
||||
"nema-10-50r",
|
||||
"nema-14-20r",
|
||||
"nema-14-30r",
|
||||
"nema-14-50r",
|
||||
"nema-14-60r",
|
||||
"nema-15-15r",
|
||||
"nema-15-20r",
|
||||
"nema-15-30r",
|
||||
"nema-15-50r",
|
||||
"nema-15-60r",
|
||||
"nema-l1-15r",
|
||||
"nema-l5-15r",
|
||||
"nema-l5-20r",
|
||||
"nema-l5-30r",
|
||||
"nema-l5-50r",
|
||||
"nema-l6-15r",
|
||||
"nema-l6-20r",
|
||||
"nema-l6-30r",
|
||||
"nema-l6-50r",
|
||||
"nema-l10-30r",
|
||||
"nema-l14-20r",
|
||||
"nema-l14-30r",
|
||||
"nema-l14-50r",
|
||||
"nema-l14-60r",
|
||||
"nema-l15-20r",
|
||||
"nema-l15-30r",
|
||||
"nema-l15-50r",
|
||||
"nema-l15-60r",
|
||||
"nema-l21-20r",
|
||||
"nema-l21-30r",
|
||||
"nema-l22-30r",
|
||||
"CS6360C",
|
||||
"CS6364C",
|
||||
"CS8164C",
|
||||
"CS8264C",
|
||||
"CS8364C",
|
||||
"CS8464C",
|
||||
"ita-e",
|
||||
"ita-f",
|
||||
"ita-g",
|
||||
"ita-h",
|
||||
"ita-i",
|
||||
"ita-j",
|
||||
"ita-k",
|
||||
"ita-l",
|
||||
"ita-m",
|
||||
"ita-n",
|
||||
"ita-o",
|
||||
"ita-multistandard",
|
||||
"usb-a",
|
||||
"usb-micro-b",
|
||||
"usb-c",
|
||||
"dc-terminal",
|
||||
"hdot-cx",
|
||||
"saf-d-grid",
|
||||
"neutrik-powercon-20a",
|
||||
"neutrik-powercon-32a",
|
||||
"neutrik-powercon-true1",
|
||||
"neutrik-powercon-true1-top",
|
||||
"ubiquiti-smartpower",
|
||||
"hardwired",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"feed-leg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"A",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"virtual",
|
||||
"bridge",
|
||||
"lag",
|
||||
"100base-fx",
|
||||
"100base-lfx",
|
||||
"100base-tx",
|
||||
"100base-t1",
|
||||
"1000base-t",
|
||||
"2.5gbase-t",
|
||||
"5gbase-t",
|
||||
"10gbase-t",
|
||||
"10gbase-cx4",
|
||||
"1000base-x-gbic",
|
||||
"1000base-x-sfp",
|
||||
"10gbase-x-sfpp",
|
||||
"10gbase-x-xfp",
|
||||
"10gbase-x-xenpak",
|
||||
"10gbase-x-x2",
|
||||
"25gbase-x-sfp28",
|
||||
"50gbase-x-sfp56",
|
||||
"40gbase-x-qsfpp",
|
||||
"50gbase-x-sfp28",
|
||||
"100gbase-x-cfp",
|
||||
"100gbase-x-cfp2",
|
||||
"200gbase-x-cfp2",
|
||||
"400gbase-x-cfp2",
|
||||
"100gbase-x-cfp4",
|
||||
"100gbase-x-cxp",
|
||||
"100gbase-x-cpak",
|
||||
"100gbase-x-dsfp",
|
||||
"100gbase-x-sfpdd",
|
||||
"100gbase-x-qsfp28",
|
||||
"100gbase-x-qsfpdd",
|
||||
"200gbase-x-qsfp56",
|
||||
"200gbase-x-qsfpdd",
|
||||
"400gbase-x-qsfp112",
|
||||
"400gbase-x-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"400gbase-x-osfp-rhs",
|
||||
"400gbase-x-cdfp",
|
||||
"400gbase-x-cfp8",
|
||||
"800gbase-x-qsfpdd",
|
||||
"800gbase-x-osfp",
|
||||
"1000base-kx",
|
||||
"10gbase-kr",
|
||||
"10gbase-kx4",
|
||||
"25gbase-kr",
|
||||
"40gbase-kr4",
|
||||
"50gbase-kr",
|
||||
"100gbase-kp4",
|
||||
"100gbase-kr2",
|
||||
"100gbase-kr4",
|
||||
"ieee802.11a",
|
||||
"ieee802.11g",
|
||||
"ieee802.11n",
|
||||
"ieee802.11ac",
|
||||
"ieee802.11ad",
|
||||
"ieee802.11ax",
|
||||
"ieee802.11ay",
|
||||
"ieee802.15.1",
|
||||
"other-wireless",
|
||||
"gsm",
|
||||
"cdma",
|
||||
"lte",
|
||||
"sonet-oc3",
|
||||
"sonet-oc12",
|
||||
"sonet-oc48",
|
||||
"sonet-oc192",
|
||||
"sonet-oc768",
|
||||
"sonet-oc1920",
|
||||
"sonet-oc3840",
|
||||
"1gfc-sfp",
|
||||
"2gfc-sfp",
|
||||
"4gfc-sfp",
|
||||
"8gfc-sfpp",
|
||||
"16gfc-sfpp",
|
||||
"32gfc-sfp28",
|
||||
"64gfc-qsfpp",
|
||||
"128gfc-qsfp28",
|
||||
"infiniband-sdr",
|
||||
"infiniband-ddr",
|
||||
"infiniband-qdr",
|
||||
"infiniband-fdr10",
|
||||
"infiniband-fdr",
|
||||
"infiniband-edr",
|
||||
"infiniband-hdr",
|
||||
"infiniband-ndr",
|
||||
"infiniband-xdr",
|
||||
"t1",
|
||||
"e1",
|
||||
"t3",
|
||||
"e3",
|
||||
"xdsl",
|
||||
"docsis",
|
||||
"gpon",
|
||||
"xg-pon",
|
||||
"xgs-pon",
|
||||
"ng-pon2",
|
||||
"epon",
|
||||
"10g-epon",
|
||||
"cisco-stackwise",
|
||||
"cisco-stackwise-plus",
|
||||
"cisco-flexstack",
|
||||
"cisco-flexstack-plus",
|
||||
"cisco-stackwise-80",
|
||||
"cisco-stackwise-160",
|
||||
"cisco-stackwise-320",
|
||||
"cisco-stackwise-480",
|
||||
"cisco-stackwise-1t",
|
||||
"juniper-vcp",
|
||||
"extreme-summitstack",
|
||||
"extreme-summitstack-128",
|
||||
"extreme-summitstack-256",
|
||||
"extreme-summitstack-512",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"poe_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pd",
|
||||
"pse"
|
||||
]
|
||||
},
|
||||
"poe_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"type1-ieee802.3af",
|
||||
"type2-ieee802.3at",
|
||||
"type3-ieee802.3bt",
|
||||
"type4-ieee802.3bt",
|
||||
"passive-24v-2pair",
|
||||
"passive-24v-4pair",
|
||||
"passive-48v-2pair",
|
||||
"passive-48v-4pair"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"front-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"8p8c",
|
||||
"8p6c",
|
||||
"8p4c",
|
||||
"8p2c",
|
||||
"6p6c",
|
||||
"6p4c",
|
||||
"6p2c",
|
||||
"4p4c",
|
||||
"4p2c",
|
||||
"gg45",
|
||||
"tera-4p",
|
||||
"tera-2p",
|
||||
"tera-1p",
|
||||
"110-punch",
|
||||
"bnc",
|
||||
"f",
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
"lc-apc",
|
||||
"lsh",
|
||||
"lsh-pc",
|
||||
"lsh-upc",
|
||||
"lsh-apc",
|
||||
"lx5",
|
||||
"lx5-pc",
|
||||
"lx5-upc",
|
||||
"lx5-apc",
|
||||
"mpo",
|
||||
"mtrj",
|
||||
"sc",
|
||||
"sc-pc",
|
||||
"sc-upc",
|
||||
"sc-apc",
|
||||
"st",
|
||||
"cs",
|
||||
"sn",
|
||||
"sma-905",
|
||||
"sma-906",
|
||||
"urm-p2",
|
||||
"urm-p4",
|
||||
"urm-p8",
|
||||
"splice",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rear-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"8p8c",
|
||||
"8p6c",
|
||||
"8p4c",
|
||||
"8p2c",
|
||||
"6p6c",
|
||||
"6p4c",
|
||||
"6p2c",
|
||||
"4p4c",
|
||||
"4p2c",
|
||||
"gg45",
|
||||
"tera-4p",
|
||||
"tera-2p",
|
||||
"tera-1p",
|
||||
"110-punch",
|
||||
"bnc",
|
||||
"f",
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
"lc-apc",
|
||||
"lsh",
|
||||
"lsh-pc",
|
||||
"lsh-upc",
|
||||
"lsh-apc",
|
||||
"lx5",
|
||||
"lx5-pc",
|
||||
"lx5-upc",
|
||||
"lx5-apc",
|
||||
"mpo",
|
||||
"mtrj",
|
||||
"sc",
|
||||
"sc-pc",
|
||||
"sc-upc",
|
||||
"sc-apc",
|
||||
"st",
|
||||
"cs",
|
||||
"sn",
|
||||
"sma-905",
|
||||
"sma-906",
|
||||
"urm-p2",
|
||||
"urm-p4",
|
||||
"urm-p8",
|
||||
"splice",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
|
||||
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
|
||||
|
||||
!!! note
|
||||
These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend).
|
||||
These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
|
||||
|
||||
### Archive the Media Directory
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'height': 3,
|
||||
'title': 'Organization',
|
||||
'config': {
|
||||
'models': [
|
||||
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Default: Empty
|
||||
|
||||
A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here.
|
||||
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
|
||||
|
||||
!!! warning
|
||||
Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Default: True
|
||||
|
||||
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
||||
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ The following methods are available to log results within a report:
|
||||
|
||||
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
|
||||
|
||||
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`.
|
||||
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
|
||||
|
||||
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
|
||||
|
||||
|
||||
123
docs/development/internationalization.md
Normal file
123
docs/development/internationalization.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Internationalization
|
||||
|
||||
Beginning with NetBox v4.0, NetBox will leverage [Django's automatic translation](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) to support languages other than English. This page details the areas of the project which require special attention to ensure functioning translation support. Briefly, these include:
|
||||
|
||||
* The `verbose_name` and `verbose_name_plural` Meta attributes for each model
|
||||
* The `verbose_name` and (if defined) `help_text` for each model field
|
||||
* The `label` for each form field
|
||||
* Headers for `fieldsets` on each form class
|
||||
* The `verbose_name` for each table column
|
||||
* All human-readable strings within templates must be wrapped with `{% trans %}` or `{% blocktrans %}`
|
||||
|
||||
The rest of this document elaborates on each of the items above.
|
||||
|
||||
## General Guidance
|
||||
|
||||
* Wrap human-readable strings with Django's `gettext()` or `gettext_lazy()` utility functions to enable automatic translation. Generally, `gettext_lazy()` is preferred (and sometimes required) to defer translation until the string is displayed.
|
||||
|
||||
* By convention, the preferred translation function is typically imported as an underscore (`_`) to minimize boilerplate code. Thus, you will often see translation as e.g. `_("Some text")`. It is still an option to import and use alternative translation functions (e.g. `pgettext()` and `ngettext()`) normally as needed.
|
||||
|
||||
* Avoid passing markup and other non-natural language where possible. Everything wrapped by a translation function gets exported to a messages file for translation by a human.
|
||||
|
||||
* Where the intended meaning of the translated string may not be obvious, use `pgettext()` or `pgettext_lazy()` to include assisting context for the translator. For example:
|
||||
|
||||
```python
|
||||
# Context, string
|
||||
pgettext("month name", "May")
|
||||
```
|
||||
|
||||
* **Format strings do not support translation.** Avoid "f" strings for messages that must support translation. Instead, use `format()` to accomplish variable replacement:
|
||||
|
||||
```python
|
||||
# Translation will not work
|
||||
f"There are {count} objects"
|
||||
|
||||
# Do this instead
|
||||
"There are {count} objects".format(count=count)
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. Ensure both `verbose_name` and `verbose_name_plural` are defined under the model's `Meta` class and wrapped with the `gettext_lazy()` shortcut.
|
||||
3. Ensure each model field specifies a `verbose_name` wrapped with `gettext_lazy()`.
|
||||
4. Ensure any `help_text` attributes on model fields are also wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class Circuit(PrimaryModel):
|
||||
commit_rate = models.PositiveIntegerField(
|
||||
...
|
||||
verbose_name=_('commit rate (Kbps)'),
|
||||
help_text=_("Committed rate")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('circuit')
|
||||
verbose_name_plural = _('circuits')
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. All form fields must specify a `label` wrapped with `gettext_lazy()`.
|
||||
3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
...
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Circuit'), ('provider', 'type', 'status', 'description')),
|
||||
)
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. All table columns must specify a `verbose_name` wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
provider = tables.Column(
|
||||
verbose_name=_('Provider'),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
|
||||
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
|
||||
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
|
||||
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
|
||||
|
||||
```
|
||||
{% load i18n %}
|
||||
|
||||
{# A short string #}
|
||||
<h5 class="card-header">{% trans "Circuit List" %}</h5>
|
||||
|
||||
{# A longer string with a context variable #}
|
||||
{% blocktrans trimmed with count=object.circuits.count %}
|
||||
There are {count} circuits. Would you like to continue?
|
||||
{% endblocktrans %}
|
||||
```
|
||||
|
||||
!!! warning
|
||||
The `{% blocktrans %}` tag supports only **limited variable replacement**, comparable to the `format()` method on Python strings. It does not permit access to object attributes or the use of other template tags or filters inside it. Ensure that any necessary context is passed as simple variables.
|
||||
|
||||
!!! info
|
||||
The `{% trans %}` and `{% blocktrans %}` support the inclusion of contextual hints for translators using the `context` argument:
|
||||
|
||||
```nohighlight
|
||||
{% trans "May" context "month name" %}
|
||||
```
|
||||
@@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
|
||||
|
||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
|
||||
|
||||
### Rebuild Demo Data (After Release)
|
||||
|
||||
After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Patch Releases
|
||||
|
||||
### Notify netbox-docker Project of Any Relevant Changes
|
||||
|
||||
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
|
||||
|
||||
* Significant changes to `upgrade.sh`
|
||||
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
|
||||
* Any changes to the reference installation
|
||||
|
||||
### Update Requirements
|
||||
|
||||
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
|
||||
@@ -58,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec
|
||||
|
||||
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
|
||||
|
||||
### Rebuild the Device Type Definition Schema
|
||||
|
||||
Run the following command to update the device type definition validation schema:
|
||||
|
||||
```nohighlight
|
||||
./manage.py buildschema --write
|
||||
```
|
||||
|
||||
This will automatically update the schema file at `contrib/generated_schema.json`.
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
* Update the `VERSION` constant in `settings.py` to the new release version.
|
||||
|
||||
@@ -37,6 +37,14 @@ Configuration templates are written in the [Jinja2 templating language](https://
|
||||
|
||||
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
|
||||
|
||||
### Context Data
|
||||
|
||||
The objet for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
|
||||
|
||||
```
|
||||
There are {{ dcim.Site.objects.count() }} sites.
|
||||
```
|
||||
|
||||
## Rendering Templates
|
||||
|
||||
### Device Configurations
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# The Premiere Network Source of Truth
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
|
||||
|
||||
|
||||
@@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
!!! warning
|
||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
||||
|
||||
## Authenticating with Active Directory
|
||||
|
||||
Integrating Active Directory for authentication can be a bit challenging as it may require handling different login formats. This solution will allow users to log in either using their full User Principal Name (UPN) or their username alone, by filtering the DN according to either the `sAMAccountName` or the `userPrincipalName`. The following configuration options will allow your users to enter their usernames in the format `username` or `username@domain.tld`.
|
||||
|
||||
Just as before, the configuration options are defined in the file ldap_config.py. First, modify the `AUTH_LDAP_USER_SEARCH` option to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
```
|
||||
|
||||
In addition, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to `None` as described in the previous sections. Next, modify `AUTH_LDAP_USER_ATTR_MAP` to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we need to add one more configuration option, `AUTH_LDAP_USER_QUERY_FIELD`. The following should be added to your LDAP configuration file:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
```
|
||||
|
||||
With these configuration options, your users will be able to log in either with or without the UPN suffix.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
!!! info
|
||||
This configuration is intended to serve as a template, but may need to be modified in accordance with your environment.
|
||||
|
||||
```python
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com:3269"
|
||||
|
||||
# The following may be needed if you are binding to Active Directory.
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0
|
||||
}
|
||||
|
||||
# Set the DN and password for the NetBox service account.
|
||||
AUTH_LDAP_BIND_DN = "CN=NETBOXSA,OU=Service Accounts,DC=example,DC=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
|
||||
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
|
||||
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
|
||||
# username is not in their DN (Active Directory).
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
|
||||
# If a user's DN is producible from their username, we don't need to search.
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = None
|
||||
|
||||
# You can map user attributes to Django attributes as so.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# hierarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
"dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
|
||||
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Mirror LDAP group assignments.
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
|
||||
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# For more granular permissions, we can map LDAP groups to Django groups.
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache groups for one hour to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
```
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
@@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
||||
|
||||
```no-highlight
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
NEWVER=3.4.9
|
||||
OLDVER=3.4.9
|
||||
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
|
||||
@@ -570,27 +570,26 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
|
||||
|
||||
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
|
||||
|
||||
!!! note
|
||||
All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts.
|
||||
By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
|
||||
|
||||
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
|
||||
|
||||
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.
|
||||
|
||||
!!! warning "Restricting Token Retrieval"
|
||||
!!! info "Restricting Token Retrieval"
|
||||
The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
|
||||
|
||||
### Restricting Write Operations
|
||||
|
||||
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.
|
||||
|
||||
#### Client IP Restriction
|
||||
|
||||
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.)
|
||||
|
||||
#### Creating Tokens for Other Users
|
||||
|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||

|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||
!!! warning "Exercise Caution"
|
||||
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
|
||||
@@ -627,7 +626,7 @@ When a token is used to authenticate a request, its `last_updated` time updated
|
||||
|
||||
### 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.
|
||||
Ideally, each user should provision his or her own API token(s) via the web UI. However, you may encounter a scenario 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. (Note that the user must have permission to create API tokens regardless of the interface used.)
|
||||
|
||||
To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint:
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 22 KiB |
@@ -23,17 +23,3 @@ If designated, this platform will be available for use only to devices assigned
|
||||
### Configuration Template
|
||||
|
||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
|
||||
|
||||
### NAPALM Driver
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
|
||||
|
||||
### NAPALM Arguments
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
Any additional arguments to send when invoking the NAPALM driver assigned to this platform.
|
||||
|
||||
@@ -26,7 +26,9 @@ Every model includes by default a numeric primary key. This value is generated a
|
||||
|
||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||
|
||||
* Bookmarks
|
||||
* Change logging
|
||||
* Cloning
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Custom validation
|
||||
@@ -105,6 +107,8 @@ For more information about database migrations, see the [Django documentation](h
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
|
||||
|
||||
::: netbox.models.features.BookmarksMixin
|
||||
|
||||
::: netbox.models.features.ChangeLoggingMixin
|
||||
|
||||
::: netbox.models.features.CloningMixin
|
||||
|
||||
@@ -64,12 +64,15 @@ item1 = PluginMenuItem(
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
|
||||
!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1."
|
||||
|
||||
## Menu Buttons
|
||||
|
||||
|
||||
@@ -61,13 +61,14 @@ These lookup expressions can be applied by adding a suffix to the desired field'
|
||||
|
||||
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
| Filter | Description |
|
||||
|---------|--------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
|
||||
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
||||
|
||||
@@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
|
||||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty (boolean) |
|
||||
| Filter | Description |
|
||||
|---------|----------------------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
|
||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||
|
||||
|
||||
@@ -10,6 +10,15 @@ 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.6](./version-3.6.md) (August 2023)
|
||||
|
||||
* Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
* Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
|
||||
* User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
|
||||
* Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
|
||||
* Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
|
||||
* Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
|
||||
|
||||
#### [Version 3.5](./version-3.5.md) (April 2023)
|
||||
|
||||
* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5.8 (FUTURE)
|
||||
## v3.5.9 (2023-08-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12489](https://github.com/netbox-community/netbox/issues/12489) - Dynamically render location and device lists under site and location views
|
||||
* [#12825](https://github.com/netbox-community/netbox/issues/12825) - Display assigned values count per obejct type under custom field view
|
||||
* [#13313](https://github.com/netbox-community/netbox/issues/13313) - Enable filtering IP ranges by containing prefix
|
||||
* [#13415](https://github.com/netbox-community/netbox/issues/13415) - Include request object in custom link renderer on tables
|
||||
* [#13536](https://github.com/netbox-community/netbox/issues/13536) - Move child VLANs list to a separate tab under VLAN group view
|
||||
* [#13542](https://github.com/netbox-community/netbox/issues/13542) - Pass additional HTTP headers through to custom script context
|
||||
* [#13585](https://github.com/netbox-community/netbox/issues/13585) - Introduce `empty` lookup for numeric value filters
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11272](https://github.com/netbox-community/netbox/issues/11272) - Fix localization support for device position field
|
||||
* [#13358](https://github.com/netbox-community/netbox/issues/13358) - Git backend should send HTTP auth headers only if credentials have been defined
|
||||
* [#13477](https://github.com/netbox-community/netbox/issues/13477) - Fix filtering of modified objects after bulk import/update
|
||||
* [#13478](https://github.com/netbox-community/netbox/issues/13478) - Fix filtering of export templates by content type under web UI
|
||||
* [#13500](https://github.com/netbox-community/netbox/issues/13500) - Fix form validation for bulk update of L2VPN terminations via bulk import form
|
||||
* [#13503](https://github.com/netbox-community/netbox/issues/13503) - Fix utilization graph proportions when localization is enabled
|
||||
* [#13507](https://github.com/netbox-community/netbox/issues/13507) - Avoid raising exception for invalid content type during global search
|
||||
* [#13516](https://github.com/netbox-community/netbox/issues/13516) - Plugin utility functions should be importable from `extras.plugins`
|
||||
* [#13530](https://github.com/netbox-community/netbox/issues/13530) - Ensure script log messages can be serialized as JSON data
|
||||
* [#13543](https://github.com/netbox-community/netbox/issues/13543) - Config context tab under device/VM view should not require `extras.view_configcontext` permission
|
||||
* [#13544](https://github.com/netbox-community/netbox/issues/13544) - Ensure `reindex` command clears all cached values when not in lazy mode
|
||||
* [#13556](https://github.com/netbox-community/netbox/issues/13556) - Correct REST API representation of VDC status choice
|
||||
* [#13569](https://github.com/netbox-community/netbox/issues/13569) - Fix selection widgets for related interfaces when bulk editing interfaces under device view
|
||||
|
||||
---
|
||||
|
||||
## v3.5.8 (2023-08-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release
|
||||
* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import
|
||||
* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI
|
||||
* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
|
||||
* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
|
||||
* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
|
||||
* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page
|
||||
* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints
|
||||
* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects
|
||||
* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links
|
||||
* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
|
||||
* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
|
||||
* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
|
||||
* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms
|
||||
* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox
|
||||
* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +1,78 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6-beta1 (2023-08-02)
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
|
||||
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
|
||||
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
|
||||
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
|
||||
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
|
||||
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
|
||||
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
|
||||
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
|
||||
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
|
||||
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
|
||||
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
|
||||
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
|
||||
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
|
||||
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
|
||||
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
|
||||
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
|
||||
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
|
||||
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
|
||||
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
|
||||
|
||||
---
|
||||
|
||||
## v3.6.1 (2023-09-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12870](https://github.com/netbox-community/netbox/issues/12870) - Support setting token expiration time using the provisioning API endpoint
|
||||
* [#13444](https://github.com/netbox-community/netbox/issues/13444) - Add bulk rename functionality to the global device component lists
|
||||
* [#13638](https://github.com/netbox-community/netbox/issues/13638) - Add optional `staff_only` attribute to MenuItem
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#12553](https://github.com/netbox-community/netbox/issues/12552) - Ensure `family` attribute is always returned when creating aggregates and prefixes via REST API
|
||||
* [#13619](https://github.com/netbox-community/netbox/issues/13619) - Fix exception when viewing IP address assigned to a virtual machine
|
||||
* [#13596](https://github.com/netbox-community/netbox/issues/13596) - Always display "render config" tab for devices and virtual machines
|
||||
* [#13620](https://github.com/netbox-community/netbox/issues/13620) - Show admin menu items only for staff users
|
||||
* [#13622](https://github.com/netbox-community/netbox/issues/13622) - Fix exception when viewing current config and no revisions have been created
|
||||
* [#13626](https://github.com/netbox-community/netbox/issues/13626) - Correct filtering of recent activity list under user view
|
||||
* [#13628](https://github.com/netbox-community/netbox/issues/13628) - Remove stale references to obsolete NAPALM integration
|
||||
* [#13630](https://github.com/netbox-community/netbox/issues/13630) - Fix display of active status under user view
|
||||
* [#13632](https://github.com/netbox-community/netbox/issues/13632) - Avoid raising exception when checking if FHRP group IP address is primary
|
||||
* [#13642](https://github.com/netbox-community/netbox/issues/13642) - Suppress warning about unreflected model changes when applying migrations
|
||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
||||
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
|
||||
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
## v3.6.0 (2023-08-30)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
|
||||
* The `boto3` and `dulwich` packages are no longer installed automatically. If needed for S3/git remote data backend support, add them to `local_requirements.txt` to ensure their installation.
|
||||
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
|
||||
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
|
||||
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
|
||||
* The `device` and `device_id` filter for interfaces will no longer include interfaces from virtual chassis peers. Two new filters, `virtual_chassis_member` and `virtual_chassis_member_id`, have been introduced to match all interfaces belonging to the specified device's virtual chassis (if any).
|
||||
* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
|
||||
* Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
#### Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
|
||||
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
|
||||
|
||||
@@ -55,15 +116,25 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
|
||||
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
|
||||
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
|
||||
* [#11478](https://github.com/netbox-community/netbox/issues/11478) - Introduce `virtual_chassis_member` filter for interfaces & restore default behavior for `device` filter
|
||||
* [#11519](https://github.com/netbox-community/netbox/issues/11519) - Add a SQL index for IP address host values to optimize queries
|
||||
* [#11732](https://github.com/netbox-community/netbox/issues/11732) - Prevent inadvertent overwriting of object attributes by competing users
|
||||
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
|
||||
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
|
||||
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
|
||||
* [#12461](https://github.com/netbox-community/netbox/issues/12461) - Add config template rendering for virtual machines
|
||||
* [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context
|
||||
* [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments
|
||||
* [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API
|
||||
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
|
||||
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13513](https://github.com/netbox-community/netbox/issues/13513) - Prevent exception when rendering bookmarks widget for anonymous user
|
||||
* [#13599](https://github.com/netbox-community/netbox/issues/13599) - Fix errant counter increments when editing device/VM components
|
||||
* [#13605](https://github.com/netbox-community/netbox/issues/13605) - Optimize cached counter migrations to avoid excessive memory consumption
|
||||
|
||||
### Other Changes
|
||||
|
||||
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.
|
||||
@@ -72,8 +143,9 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
|
||||
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
|
||||
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
|
||||
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
|
||||
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||
* [#12906](https://github.com/netbox-community/netbox/issues/12906) - The `boto3` (AWS) and `dulwich` (git) packages for remote data sources are now optional requirements
|
||||
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11
|
||||
* [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
|
||||
|
||||
@@ -112,6 +184,10 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* extras.CustomField
|
||||
* Removed the `choices` array field
|
||||
* Added the `choice_set` foreign key field (to ChoiceSet)
|
||||
* extras.Report
|
||||
* Reports are now returned within a `results` list
|
||||
* extras.Script
|
||||
* Scripts are now returned within a `results` list
|
||||
* extras.Tag
|
||||
* Added the `object_types` field for optional restriction to specific object types
|
||||
* extras.Webhook
|
||||
|
||||
@@ -211,6 +211,7 @@ nav:
|
||||
- ConfigContext: 'models/extras/configcontext.md'
|
||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||
- CustomField: 'models/extras/customfield.md'
|
||||
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
||||
- CustomLink: 'models/extras/customlink.md'
|
||||
- ExportTemplate: 'models/extras/exporttemplate.md'
|
||||
- ImageAttachment: 'models/extras/imageattachment.md'
|
||||
@@ -270,6 +271,7 @@ nav:
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Web UI: 'development/web-ui.md'
|
||||
- Internationalization: 'development/internationalization.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||
- Release Notes:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -6,9 +5,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.choices import *
|
||||
from dcim.models import CabledObjectModel
|
||||
from netbox.models import (
|
||||
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
|
||||
)
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'Circuit',
|
||||
@@ -25,8 +23,13 @@ class CircuitType(OrganizationalModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuittype', args=[self.pk])
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('circuit type')
|
||||
verbose_name_plural = _('circuit types')
|
||||
|
||||
class Circuit(PrimaryModel):
|
||||
|
||||
class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
|
||||
circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
|
||||
@@ -84,14 +87,6 @@ class Circuit(PrimaryModel):
|
||||
help_text=_("Committed rate")
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
# Cache associated CircuitTerminations
|
||||
termination_a = models.ForeignKey(
|
||||
to='circuits.CircuitTermination',
|
||||
@@ -131,6 +126,8 @@ class Circuit(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
|
||||
),
|
||||
)
|
||||
verbose_name = _('circuit')
|
||||
verbose_name_plural = _('circuits')
|
||||
|
||||
def __str__(self):
|
||||
return self.cid
|
||||
@@ -217,6 +214,8 @@ class CircuitTermination(
|
||||
name='%(app_label)s_%(class)s_unique_circuit_term_side'
|
||||
),
|
||||
)
|
||||
verbose_name = _('circuit termination')
|
||||
verbose_name_plural = _('circuit terminations')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.circuit}: Termination {self.term_side}'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ContactsMixin
|
||||
|
||||
__all__ = (
|
||||
'ProviderNetwork',
|
||||
@@ -13,7 +13,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Provider(PrimaryModel):
|
||||
class Provider(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
stores information pertinent to the user's relationship with the Provider.
|
||||
@@ -35,15 +35,12 @@ class Provider(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
clone_fields = ()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('provider')
|
||||
verbose_name_plural = _('providers')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -52,7 +49,7 @@ class Provider(PrimaryModel):
|
||||
return reverse('circuits:provider', args=[self.pk])
|
||||
|
||||
|
||||
class ProviderAccount(PrimaryModel):
|
||||
class ProviderAccount(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
This is a discrete account within a provider. Each Circuit belongs to a Provider Account.
|
||||
"""
|
||||
@@ -71,11 +68,6 @@ class ProviderAccount(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
clone_fields = ('provider', )
|
||||
|
||||
class Meta:
|
||||
@@ -91,6 +83,8 @@ class ProviderAccount(PrimaryModel):
|
||||
condition=~Q(name="")
|
||||
),
|
||||
)
|
||||
verbose_name = _('provider account')
|
||||
verbose_name_plural = _('provider accounts')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
@@ -129,6 +123,8 @@ class ProviderNetwork(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_provider_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('provider network')
|
||||
verbose_name_plural = _('provider networks')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
related_models = (
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'providernetwork_id',
|
||||
'provider_network_id',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
|
||||
|
||||
# Use our custom destructor to ignore certain attributes when calculating field migrations
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
|
||||
@@ -14,8 +14,8 @@ class DataSourceTypeChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOCAL, _('Local'), 'gray'),
|
||||
(GIT, _('Git'), 'blue'),
|
||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
||||
(GIT, 'Git', 'blue'),
|
||||
(AMAZON_S3, 'Amazon S3', 'blue'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ class GitBackend(DataBackend):
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
@@ -125,12 +125,13 @@ class GitBackend(DataBackend):
|
||||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
if self.params.get('username'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Cloning git repo: {self.url}")
|
||||
try:
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.conf import settings
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.management.commands.makemigrations import Command as _Command
|
||||
from django.db import models
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
|
||||
|
||||
class Command(_Command):
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.core.management.commands.migrate import Command
|
||||
from django.db import models
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
@@ -83,6 +83,8 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('data source')
|
||||
verbose_name_plural = _('data sources')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
@@ -300,6 +302,8 @@ class DataFile(models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=('source', 'path'), name='core_datafile_source_path'),
|
||||
]
|
||||
verbose_name = _('data file')
|
||||
verbose_name_plural = _('data files')
|
||||
|
||||
def __str__(self):
|
||||
return self.path
|
||||
@@ -312,7 +316,7 @@ class DataFile(models.Model):
|
||||
if not self.data:
|
||||
return None
|
||||
try:
|
||||
return bytes(self.data, 'utf-8')
|
||||
return self.data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
@@ -383,3 +387,5 @@ class AutoSyncRecord(models.Model):
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id')),
|
||||
)
|
||||
verbose_name = _('auto sync record')
|
||||
verbose_name_plural = _('auto sync records')
|
||||
|
||||
@@ -56,6 +56,8 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
|
||||
]
|
||||
verbose_name = _('managed file')
|
||||
verbose_name_plural = _('managed files')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -101,6 +101,8 @@ class Job(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('job')
|
||||
verbose_name_plural = _('jobs')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.job_id)
|
||||
|
||||
@@ -25,4 +25,7 @@ urlpatterns = (
|
||||
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||
|
||||
# Configuration
|
||||
path('config/', views.ConfigView.as_view(), name='config'),
|
||||
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from utilities.utils import count_related
|
||||
@@ -141,3 +143,19 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
filterset = filtersets.JobFilterSet
|
||||
table = tables.JobTable
|
||||
|
||||
|
||||
#
|
||||
# Config Revisions
|
||||
#
|
||||
|
||||
class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if config := self.queryset.first():
|
||||
return config
|
||||
# Instantiate a dummy default config if none has been created yet
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
@@ -738,12 +738,12 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
|
||||
'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
|
||||
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
|
||||
'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
||||
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
|
||||
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
|
||||
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
|
||||
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
@@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
@@ -786,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.JSONField()
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
@@ -836,8 +836,11 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
@@ -978,6 +981,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
|
||||
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
|
||||
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
|
||||
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
|
||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||
@@ -987,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
@@ -1141,6 +1147,8 @@ class InterfaceSpeedChoices(ChoiceSet):
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1462,17 +1462,15 @@ class InterfaceFilterSet(
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
# members
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
virtual_chassis_member = MultiValueCharFilter(
|
||||
method='filter_virtual_chassis_member',
|
||||
field_name='name',
|
||||
label=_('Device'),
|
||||
label=_('Virtual Chassis Interfaces for Device')
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device_id',
|
||||
virtual_chassis_member_id = MultiValueNumberFilter(
|
||||
method='filter_virtual_chassis_member',
|
||||
field_name='pk',
|
||||
label=_('Device (ID)'),
|
||||
label=_('Virtual Chassis Interfaces for Device (ID)')
|
||||
)
|
||||
kind = django_filters.CharFilter(
|
||||
method='filter_kind',
|
||||
@@ -1540,23 +1538,11 @@ class InterfaceFilterSet(
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
||||
]
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
def filter_virtual_chassis_member(self, queryset, name, value):
|
||||
try:
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def filter_device_id(self, queryset, name, id_list):
|
||||
# Include interfaces belonging to peer virtual chassis members
|
||||
vc_interface_ids = []
|
||||
try:
|
||||
devices = Device.objects.filter(pk__in=id_list)
|
||||
for device in devices:
|
||||
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
|
||||
for device in Device.objects.filter(**{f'{name}__in': value}):
|
||||
vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
help_texts = {
|
||||
'time_zone': mark_safe(
|
||||
_('Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)')
|
||||
'{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
|
||||
_('Time zone'), _('available options')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
model = DeviceRole
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
|
||||
help_text=mark_safe(
|
||||
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
|
||||
)
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
model = InventoryItemRole
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side a device'),
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side A device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_a_type = CSVContentTypeField(
|
||||
label=_('Side a type'),
|
||||
label=_('Side A type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side A type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
label=_('Side a name'),
|
||||
help_text=_('Side A component name')
|
||||
label=_('Side A name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Termination B
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side b device'),
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side B device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_b_type = CSVContentTypeField(
|
||||
label=_('Side b type'),
|
||||
label=_('Side B type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side B type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
label=_('Side b name'),
|
||||
help_text=_('Side B component name')
|
||||
label=_('Side B name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Cable attributes
|
||||
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
def _clean_side(self, side):
|
||||
|
||||
@@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_("The lowest-numbered unit occupied by the device"),
|
||||
localize=True,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
attrs={
|
||||
'disabled-indicator': 'device',
|
||||
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
@@ -1098,6 +1099,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual device contexts'),
|
||||
initial_params={
|
||||
'interfaces': '$parent',
|
||||
},
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
@@ -1107,7 +1111,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('Parent interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
bridge = DynamicModelChoiceField(
|
||||
@@ -1115,7 +1119,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('Bridged interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
lag = DynamicModelChoiceField(
|
||||
@@ -1123,7 +1127,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('LAG interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
'type': 'lag',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,7 +55,10 @@ class ComponentCreateForm(forms.Form):
|
||||
super().clean()
|
||||
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
pattern_count = len(self.cleaned_data[self.replication_fields[0]])
|
||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||
return
|
||||
|
||||
pattern_count = len(patterns)
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
|
||||
62
netbox/dcim/management/commands/buildschema.py
Normal file
62
netbox/dcim/management/commands/buildschema.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
from dcim.choices import *
|
||||
|
||||
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
|
||||
OUTPUT_FILENAME = 'contrib/generated_schema.json'
|
||||
|
||||
CHOICES_MAP = {
|
||||
'airflow_choices': DeviceAirflowChoices,
|
||||
'weight_unit_choices': WeightUnitChoices,
|
||||
'subdevice_role_choices': SubdeviceRoleChoices,
|
||||
'console_port_type_choices': ConsolePortTypeChoices,
|
||||
'console_server_port_type_choices': ConsolePortTypeChoices,
|
||||
'power_port_type_choices': PowerPortTypeChoices,
|
||||
'power_outlet_type_choices': PowerOutletTypeChoices,
|
||||
'power_outlet_feedleg_choices': PowerOutletFeedLegChoices,
|
||||
'interface_type_choices': InterfaceTypeChoices,
|
||||
'interface_poe_mode_choices': InterfacePoEModeChoices,
|
||||
'interface_poe_type_choices': InterfacePoETypeChoices,
|
||||
'front_port_type_choices': PortTypeChoices,
|
||||
'rear_port_type_choices': PortTypeChoices,
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate JSON schema for validating NetBox device type definitions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--write',
|
||||
action='store_true',
|
||||
help="Write the generated schema to file"
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Initialize template
|
||||
template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/')
|
||||
template_env = Environment(loader=template_loader)
|
||||
template = template_env.get_template(TEMPLATE_FILENAME)
|
||||
|
||||
# Render template
|
||||
context = {
|
||||
key: json.dumps(choices.values())
|
||||
for key, choices in CHOICES_MAP.items()
|
||||
}
|
||||
rendered = template.render(**context)
|
||||
|
||||
if kwargs['write']:
|
||||
# $root/contrib/generated_schema.json
|
||||
filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME)
|
||||
with open(filename, mode='w', encoding='UTF-8') as f:
|
||||
f.write(json.dumps(json.loads(rendered), indent=4))
|
||||
f.write('\n')
|
||||
f.close()
|
||||
self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}."))
|
||||
else:
|
||||
self.stdout.write(rendered)
|
||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='config_template',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicerole',
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_device_counts(apps, schema_editor):
|
||||
Device = apps.get_model("dcim", "Device")
|
||||
devices = list(Device.objects.all().annotate(
|
||||
_console_port_count=Count('consoleports', distinct=True),
|
||||
_console_server_port_count=Count('consoleserverports', distinct=True),
|
||||
_power_port_count=Count('powerports', distinct=True),
|
||||
_power_outlet_count=Count('poweroutlets', distinct=True),
|
||||
_interface_count=Count('interfaces', distinct=True),
|
||||
_front_port_count=Count('frontports', distinct=True),
|
||||
_rear_port_count=Count('rearports', distinct=True),
|
||||
_device_bay_count=Count('devicebays', distinct=True),
|
||||
_module_bay_count=Count('modulebays', distinct=True),
|
||||
_inventory_item_count=Count('inventoryitems', distinct=True),
|
||||
))
|
||||
|
||||
for device in devices:
|
||||
device.console_port_count = device._console_port_count
|
||||
device.console_server_port_count = device._console_server_port_count
|
||||
device.power_port_count = device._power_port_count
|
||||
device.power_outlet_count = device._power_outlet_count
|
||||
device.interface_count = device._interface_count
|
||||
device.front_port_count = device._front_port_count
|
||||
device.rear_port_count = device._rear_port_count
|
||||
device.device_bay_count = device._device_bay_count
|
||||
device.module_bay_count = device._module_bay_count
|
||||
device.inventory_item_count = device._inventory_item_count
|
||||
|
||||
Device.objects.bulk_update(devices, [
|
||||
'console_port_count',
|
||||
'console_server_port_count',
|
||||
'power_port_count',
|
||||
'power_outlet_count',
|
||||
'interface_count',
|
||||
'front_port_count',
|
||||
'rear_port_count',
|
||||
'device_bay_count',
|
||||
'module_bay_count',
|
||||
'inventory_item_count',
|
||||
])
|
||||
update_counts(Device, 'console_port_count', 'consoleports')
|
||||
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||
update_counts(Device, 'power_port_count', 'powerports')
|
||||
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||
update_counts(Device, 'interface_count', 'interfaces')
|
||||
update_counts(Device, 'front_port_count', 'frontports')
|
||||
update_counts(Device, 'rear_port_count', 'rearports')
|
||||
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_devicetype_template_counts(apps, schema_editor):
|
||||
DeviceType = apps.get_model("dcim", "DeviceType")
|
||||
device_types = list(DeviceType.objects.all().annotate(
|
||||
_console_port_template_count=Count('consoleporttemplates', distinct=True),
|
||||
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
|
||||
_power_port_template_count=Count('powerporttemplates', distinct=True),
|
||||
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
|
||||
_interface_template_count=Count('interfacetemplates', distinct=True),
|
||||
_front_port_template_count=Count('frontporttemplates', distinct=True),
|
||||
_rear_port_template_count=Count('rearporttemplates', distinct=True),
|
||||
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
|
||||
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
|
||||
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
|
||||
))
|
||||
|
||||
for devicetype in device_types:
|
||||
devicetype.console_port_template_count = devicetype._console_port_template_count
|
||||
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
|
||||
devicetype.power_port_template_count = devicetype._power_port_template_count
|
||||
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
|
||||
devicetype.interface_template_count = devicetype._interface_template_count
|
||||
devicetype.front_port_template_count = devicetype._front_port_template_count
|
||||
devicetype.rear_port_template_count = devicetype._rear_port_template_count
|
||||
devicetype.device_bay_template_count = devicetype._device_bay_template_count
|
||||
devicetype.module_bay_template_count = devicetype._module_bay_template_count
|
||||
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
|
||||
|
||||
DeviceType.objects.bulk_update(device_types, [
|
||||
'console_port_template_count',
|
||||
'console_server_port_template_count',
|
||||
'power_port_template_count',
|
||||
'power_outlet_template_count',
|
||||
'interface_template_count',
|
||||
'front_port_template_count',
|
||||
'rear_port_template_count',
|
||||
'device_bay_template_count',
|
||||
'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
])
|
||||
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def populate_virtualchassis_members(apps, schema_editor):
|
||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||
|
||||
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
|
||||
|
||||
for vc in vcs:
|
||||
vc.member_count = vc._member_count
|
||||
|
||||
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
|
||||
update_counts(VirtualChassis, 'member_count', 'members')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -91,15 +91,17 @@ class Cable(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
verbose_name = _('cable')
|
||||
verbose_name_plural = _('cables')
|
||||
|
||||
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
|
||||
self._pk = self.pk
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
@@ -292,6 +294,8 @@ class CableTermination(ChangeLoggedModel):
|
||||
name='%(app_label)s_%(class)s_unique_termination'
|
||||
),
|
||||
)
|
||||
verbose_name = _('cable termination')
|
||||
verbose_name_plural = _('cable terminations')
|
||||
|
||||
def __str__(self):
|
||||
return f'Cable {self.cable} to {self.termination}'
|
||||
@@ -427,6 +431,10 @@ class CablePath(models.Model):
|
||||
)
|
||||
_nodes = PathField()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('cable path')
|
||||
verbose_name_plural = _('cable paths')
|
||||
|
||||
def __str__(self):
|
||||
return f"Path #{self.pk}: {len(self.path)} hops"
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
self._original_device_type = self.__dict__.get('device_type_id')
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
@@ -183,6 +183,10 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = ConsolePort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('console port template')
|
||||
verbose_name_plural = _('console port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -213,6 +217,10 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = ConsoleServerPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('console server port template')
|
||||
verbose_name_plural = _('console server port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -258,6 +266,10 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = PowerPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('power port template')
|
||||
verbose_name_plural = _('power port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -316,6 +328,10 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = PowerOutlet
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('power outlet template')
|
||||
verbose_name_plural = _('power outlet templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -410,6 +426,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = Interface
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('interface template')
|
||||
verbose_name_plural = _('interface templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -503,6 +523,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
name='%(app_label)s_%(class)s_unique_rear_port_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('front port template')
|
||||
verbose_name_plural = _('front port templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -579,6 +601,10 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = RearPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('rear port template')
|
||||
verbose_name_plural = _('rear port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -614,6 +640,10 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
||||
|
||||
component_model = ModuleBay
|
||||
|
||||
class Meta(ComponentTemplateModel.Meta):
|
||||
verbose_name = _('module bay template')
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def instantiate(self, device):
|
||||
return self.component_model(
|
||||
device=device,
|
||||
@@ -638,6 +668,10 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
component_model = DeviceBay
|
||||
|
||||
class Meta(ComponentTemplateModel.Meta):
|
||||
verbose_name = _('device bay template')
|
||||
verbose_name_plural = _('device bay templates')
|
||||
|
||||
def instantiate(self, device):
|
||||
return self.component_model(
|
||||
device=device,
|
||||
@@ -720,6 +754,8 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
name='%(app_label)s_%(class)s_unique_device_type_parent_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('inventory item template')
|
||||
verbose_name_plural = _('inventory item templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
|
||||
|
||||
@@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
self._original_device = self.__dict__.get('device_id')
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
@@ -298,6 +298,10 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('console port')
|
||||
verbose_name_plural = _('console ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -323,6 +327,10 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint,
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('console server port')
|
||||
verbose_name_plural = _('console server ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -359,6 +367,10 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
|
||||
|
||||
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('power port')
|
||||
verbose_name_plural = _('power ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -473,6 +485,10 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('power outlet')
|
||||
verbose_name_plural = _('power outlets')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -718,6 +734,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
verbose_name = _('interface')
|
||||
verbose_name_plural = _('interfaces')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:interface', kwargs={'pk': self.pk})
|
||||
@@ -781,9 +799,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
if self.bridge and self.bridge.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
raise ValidationError({
|
||||
'bridge': _("""
|
||||
The selected bridge interface ({bridge}) belongs to a different device
|
||||
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
|
||||
'bridge': _(
|
||||
"The selected bridge interface ({bridge}) belongs to a different device ({device})."
|
||||
).format(bridge=self.bridge, device=self.bridge.device)
|
||||
})
|
||||
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
|
||||
raise ValidationError({
|
||||
@@ -871,10 +889,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': _("""
|
||||
The untagged VLAN ({untagged_vlan}) must belong to the same site as the
|
||||
interface's parent device, or it must be global.
|
||||
""").format(untagged_vlan=self.untagged_vlan)
|
||||
'untagged_vlan': _(
|
||||
"The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
|
||||
"device, or it must be global."
|
||||
).format(untagged_vlan=self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -977,6 +995,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_rear_port_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('front port')
|
||||
verbose_name_plural = _('front ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:frontport', kwargs={'pk': self.pk})
|
||||
@@ -1032,6 +1052,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
)
|
||||
clone_fields = ('device', 'type', 'color', 'positions')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('rear port')
|
||||
verbose_name_plural = _('rear ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1043,9 +1067,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": _("""
|
||||
The number of positions cannot be less than the number of mapped front ports
|
||||
({frontport_count})""").format(frontport_count=frontport_count)
|
||||
"positions": _(
|
||||
"The number of positions cannot be less than the number of mapped front ports "
|
||||
"({frontport_count})"
|
||||
).format(frontport_count=frontport_count)
|
||||
})
|
||||
|
||||
|
||||
@@ -1066,6 +1091,10 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
|
||||
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('module bay')
|
||||
verbose_name_plural = _('module bays')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1084,6 +1113,10 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('device bay')
|
||||
verbose_name_plural = _('device bays')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1125,6 +1158,11 @@ class InventoryItemRole(OrganizationalModel):
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('inventory item role')
|
||||
verbose_name_plural = _('inventory item roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:inventoryitemrole', args=[self.pk])
|
||||
|
||||
@@ -1209,6 +1247,8 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_device_parent_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('inventory item')
|
||||
verbose_name_plural = _('inventory items')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -3,7 +3,6 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -20,11 +19,12 @@ from extras.models import ConfigContextModel
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from .device_components import *
|
||||
from .mixins import WeightMixin
|
||||
from .mixins import RenderConfigMixin, WeightMixin
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -44,20 +44,20 @@ __all__ = (
|
||||
# Device Types
|
||||
#
|
||||
|
||||
class Manufacturer(OrganizationalModel):
|
||||
class Manufacturer(ContactsMixin, OrganizationalModel):
|
||||
"""
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
"""
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('manufacturer')
|
||||
verbose_name_plural = _('manufacturers')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:manufacturer', args=[self.pk])
|
||||
|
||||
|
||||
class DeviceType(PrimaryModel, WeightMixin):
|
||||
class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
|
||||
well as high-level functional role(s).
|
||||
@@ -175,10 +175,6 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
to_field='device_type'
|
||||
)
|
||||
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||
'weight_unit',
|
||||
@@ -199,6 +195,8 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_manufacturer_slug'
|
||||
),
|
||||
)
|
||||
verbose_name = _('device type')
|
||||
verbose_name_plural = _('device types')
|
||||
|
||||
def __str__(self):
|
||||
return self.model
|
||||
@@ -207,11 +205,11 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
self._original_u_height = self.__dict__.get('u_height')
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
self._original_front_image = self.__dict__.get('front_image')
|
||||
self._original_rear_image = self.__dict__.get('rear_image')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@@ -359,7 +357,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
|
||||
|
||||
|
||||
class ModuleType(PrimaryModel, WeightMixin):
|
||||
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
A ModuleType represents a hardware element that can be installed within a device and which houses additional
|
||||
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
|
||||
@@ -382,11 +380,6 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
help_text=_('Discrete part number (optional)')
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
@@ -400,6 +393,8 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_manufacturer_model'
|
||||
),
|
||||
)
|
||||
verbose_name = _('module type')
|
||||
verbose_name_plural = _('module types')
|
||||
|
||||
def __str__(self):
|
||||
return self.model
|
||||
@@ -477,6 +472,11 @@ class DeviceRole(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('device role')
|
||||
verbose_name_plural = _('device roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicerole', args=[self.pk])
|
||||
|
||||
@@ -502,6 +502,11 @@ class Platform(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('platform')
|
||||
verbose_name_plural = _('platforms')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:platform', args=[self.pk])
|
||||
|
||||
@@ -520,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None):
|
||||
interface.save()
|
||||
|
||||
|
||||
class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
class Device(
|
||||
ContactsMixin,
|
||||
ImageAttachmentsMixin,
|
||||
RenderConfigMixin,
|
||||
ConfigContextModel,
|
||||
TrackingModelMixin,
|
||||
PrimaryModel
|
||||
):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
|
||||
@@ -681,13 +693,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
validators=[MaxValueValidator(255)],
|
||||
help_text=_('Virtual chassis master election priority')
|
||||
)
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='devices',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
latitude = models.DecimalField(
|
||||
verbose_name=_('latitude'),
|
||||
max_digits=8,
|
||||
@@ -747,14 +752,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
to_field='device'
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
@@ -789,6 +786,8 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('device')
|
||||
verbose_name_plural = _('devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name and self.asset_tag:
|
||||
@@ -1071,17 +1070,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
def interfaces_count(self):
|
||||
return self.vc_interfaces().count()
|
||||
|
||||
def get_config_template(self):
|
||||
"""
|
||||
Return the appropriate ConfigTemplate (if any) for this Device.
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
||||
def get_vc_master(self):
|
||||
"""
|
||||
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||
@@ -1182,6 +1170,8 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('module_bay',)
|
||||
verbose_name = _('module')
|
||||
verbose_name_plural = _('modules')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
|
||||
@@ -1314,7 +1304,8 @@ class VirtualChassis(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
verbose_name = _('virtual chassis')
|
||||
verbose_name_plural = _('virtual chassis')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -1415,6 +1406,8 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_device_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('virtual device context')
|
||||
verbose_name_plural = _('virtual device contexts')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.choices import *
|
||||
from utilities.utils import to_grams
|
||||
|
||||
__all__ = (
|
||||
'RenderConfigMixin',
|
||||
'WeightMixin',
|
||||
)
|
||||
|
||||
|
||||
class WeightMixin(models.Model):
|
||||
weight = models.DecimalField(
|
||||
@@ -44,3 +49,27 @@ class WeightMixin(models.Model):
|
||||
# Validate weight and weight_unit
|
||||
if self.weight and not self.weight_unit:
|
||||
raise ValidationError(_("Must specify a unit when setting a weight"))
|
||||
|
||||
|
||||
class RenderConfigMixin(models.Model):
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='%(class)ss',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_config_template(self):
|
||||
"""
|
||||
Return the appropriate ConfigTemplate (if any) for this Device.
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role and self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -8,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.choices import *
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import CabledObjectModel, PathEndpoint
|
||||
|
||||
@@ -21,7 +21,7 @@ __all__ = (
|
||||
# Power
|
||||
#
|
||||
|
||||
class PowerPanel(PrimaryModel):
|
||||
class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
@@ -40,14 +40,6 @@ class PowerPanel(PrimaryModel):
|
||||
max_length=100
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
@@ -60,6 +52,8 @@ class PowerPanel(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_site_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('power panel')
|
||||
verbose_name_plural = _('power panels')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -166,6 +160,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
name='%(app_label)s_%(class)s_unique_power_panel_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('power feed')
|
||||
verbose_name_plural = _('power feeds')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -178,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
powerpanel=self.power_panel,
|
||||
powerpanel_site=self.power_panel.site
|
||||
))
|
||||
|
||||
# AC voltage cannot be negative
|
||||
|
||||
@@ -15,6 +15,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.utils import array_to_string, drange, to_grams
|
||||
@@ -43,11 +44,16 @@ class RackRole(OrganizationalModel):
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('rack role')
|
||||
verbose_name_plural = _('rack roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackrole', args=[self.pk])
|
||||
|
||||
|
||||
class Rack(PrimaryModel, WeightMixin):
|
||||
class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a Location.
|
||||
@@ -188,12 +194,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='rack'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
@@ -216,6 +216,8 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_location_facility_id'
|
||||
),
|
||||
)
|
||||
verbose_name = _('rack')
|
||||
verbose_name_plural = _('racks')
|
||||
|
||||
def __str__(self):
|
||||
if self.facility_id:
|
||||
@@ -538,6 +540,8 @@ class RackReservation(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
verbose_name = _('rack reservation')
|
||||
verbose_name_plural = _('rack reservations')
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
@@ -8,6 +8,7 @@ from timezone_field import TimeZoneField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from netbox.models import NestedGroupModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.fields import NaturalOrderingField
|
||||
|
||||
__all__ = (
|
||||
@@ -22,22 +23,18 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
class Region(NestedGroupModel):
|
||||
class Region(ContactsMixin, NestedGroupModel):
|
||||
"""
|
||||
A region represents a geographic collection of sites. For example, you might create regions representing countries,
|
||||
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
|
||||
also considered to be members of its parent and ancestor region(s).
|
||||
"""
|
||||
# Generic relations
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='region'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = (
|
||||
@@ -62,6 +59,8 @@ class Region(NestedGroupModel):
|
||||
violation_error_message=_("A top-level region with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('region')
|
||||
verbose_name_plural = _('regions')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:region', args=[self.pk])
|
||||
@@ -77,22 +76,18 @@ class Region(NestedGroupModel):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroup(NestedGroupModel):
|
||||
class SiteGroup(ContactsMixin, NestedGroupModel):
|
||||
"""
|
||||
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
|
||||
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
|
||||
nested recursively to form a hierarchy.
|
||||
"""
|
||||
# Generic relations
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site_group'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = (
|
||||
@@ -117,6 +112,8 @@ class SiteGroup(NestedGroupModel):
|
||||
violation_error_message=_("A top-level site group with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('site group')
|
||||
verbose_name_plural = _('site groups')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:sitegroup', args=[self.pk])
|
||||
@@ -132,7 +129,7 @@ class SiteGroup(NestedGroupModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class Site(PrimaryModel):
|
||||
class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
|
||||
@@ -230,12 +227,6 @@ class Site(PrimaryModel):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
|
||||
@@ -244,6 +235,8 @@ class Site(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name',)
|
||||
verbose_name = _('site')
|
||||
verbose_name_plural = _('sites')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -259,7 +252,7 @@ class Site(PrimaryModel):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class Location(NestedGroupModel):
|
||||
class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
|
||||
"""
|
||||
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
|
||||
site, or a room within a building, for example.
|
||||
@@ -290,12 +283,6 @@ class Location(NestedGroupModel):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='location'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
|
||||
prerequisite_models = (
|
||||
@@ -326,6 +313,8 @@ class Location(NestedGroupModel):
|
||||
violation_error_message=_("A location with this slug already exists within the specified site.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('location')
|
||||
verbose_name_plural = _('locations')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
@@ -591,7 +591,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
}
|
||||
)
|
||||
mgmt_only = columns.BooleanColumn(
|
||||
verbose_name=_('Management Only'),
|
||||
verbose_name=_('Management Only')
|
||||
)
|
||||
speed_formatted = columns.TemplateColumn(
|
||||
template_code='{% load helpers %}{{ value|humanize_speed }}',
|
||||
accessor=Accessor('speed'),
|
||||
verbose_name=_('Speed')
|
||||
)
|
||||
wireless_link = tables.Column(
|
||||
verbose_name=_('Wireless link'),
|
||||
@@ -618,7 +623,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
@@ -866,8 +871,9 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
verbose_name=_('Module Status'),
|
||||
template_code=MODULEBAY_STATUS
|
||||
accessor=tables.A('installed_module__status'),
|
||||
template_code=MODULEBAY_STATUS,
|
||||
verbose_name=_('Module Status')
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
|
||||
@@ -2822,11 +2822,56 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
||||
virtual_chassis.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
|
||||
Device(
|
||||
name='Device 1A',
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
location=locations[0],
|
||||
rack=racks[0],
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=1,
|
||||
vc_priority=1
|
||||
),
|
||||
Device(
|
||||
name='Device 1B',
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
rack=racks[2],
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=2,
|
||||
vc_priority=1
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
location=locations[1],
|
||||
rack=racks[1]
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
rack=racks[2]
|
||||
),
|
||||
# For cable connections
|
||||
Device(
|
||||
name=None,
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[3]
|
||||
),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2834,6 +2879,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
ModuleBay(device=devices[3], name='Module Bay 4'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
@@ -2841,6 +2887,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
Module(device=devices[3], module_bay=module_bays[3], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
@@ -2853,16 +2900,11 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
|
||||
# Virtual Device Context Creation
|
||||
vdcs = (
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
VirtualDeviceContext(device=devices[4], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[4], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||
|
||||
interfaces = (
|
||||
Interface(
|
||||
device=devices[0],
|
||||
@@ -2885,6 +2927,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Interface(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='VC Chassis Interface',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||
enabled=True
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Interface 2',
|
||||
label='B',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
@@ -2901,8 +2950,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
device=devices[3],
|
||||
module=modules[3],
|
||||
name='Interface 3',
|
||||
label='C',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
@@ -2919,7 +2968,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 4',
|
||||
label='D',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2932,7 +2981,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 5',
|
||||
label='E',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2941,7 +2990,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 6',
|
||||
label='F',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2950,7 +2999,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 7',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||
@@ -2959,7 +3008,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_channel_width=22
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 8',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||
@@ -2977,8 +3026,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
interfaces[7].vdcs.set([vdcs[1]])
|
||||
|
||||
# Cables
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
|
||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
|
||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
|
||||
# Third pair is not connected
|
||||
|
||||
def test_name(self):
|
||||
@@ -2991,7 +3040,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
params = {'enabled': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -3011,7 +3060,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'mgmt_only': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'mgmt_only': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_poe_mode(self):
|
||||
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
|
||||
@@ -3116,6 +3165,14 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_chassis_member(self):
|
||||
# Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
|
||||
devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
|
||||
params = {'virtual_chassis_member_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'virtual_chassis_member': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[1].pk]}
|
||||
@@ -3125,23 +3182,23 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_kind(self):
|
||||
params = {'kind': 'physical'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
params = {'kind': 'virtual'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -2014,6 +2014,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2030,6 +2031,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,6 +2108,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = {
|
||||
|
||||
|
||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
'bulk_disconnect': {'change'},
|
||||
})
|
||||
queryset = Device.objects.all()
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -388,32 +398,8 @@ class SiteView(generic.ObjectView):
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||
)
|
||||
|
||||
locations = Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
)
|
||||
locations = Location.objects.add_related_count(
|
||||
locations,
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=instance)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
site=instance,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'locations': locations,
|
||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -485,16 +471,8 @@ class LocationView(generic.ObjectView):
|
||||
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
||||
)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1997,6 +1975,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceModuleBayTable
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.module_bay_count,
|
||||
@@ -2012,6 +1991,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceDeviceBayTable
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.device_bay_count,
|
||||
@@ -2023,6 +2003,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
|
||||
@register_model_view(Device, 'inventory')
|
||||
class DeviceInventoryView(DeviceComponentsView):
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
child_model = InventoryItem
|
||||
table = tables.DeviceInventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
@@ -2042,7 +2023,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
@@ -2053,7 +2033,6 @@ class DeviceRenderConfigView(generic.ObjectView):
|
||||
template_name = 'dcim/device/render_config.html'
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
permission='extras.view_configtemplate',
|
||||
weight=2100
|
||||
)
|
||||
|
||||
@@ -2205,6 +2184,15 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ConsolePort)
|
||||
@@ -2268,6 +2256,15 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort)
|
||||
@@ -2331,6 +2328,15 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(PowerPort)
|
||||
@@ -2394,6 +2400,15 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet)
|
||||
@@ -2457,6 +2472,15 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Interface)
|
||||
@@ -2568,6 +2592,15 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(FrontPort)
|
||||
@@ -2631,6 +2664,15 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(RearPort)
|
||||
@@ -2694,6 +2736,15 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ModuleBay)
|
||||
@@ -2749,6 +2800,15 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(DeviceBay)
|
||||
@@ -2873,6 +2933,15 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(InventoryItem)
|
||||
|
||||
@@ -239,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
|
||||
class TagSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
object_types = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
||||
many=True,
|
||||
required=False
|
||||
)
|
||||
@@ -479,8 +479,13 @@ class ReportSerializer(serializers.Serializer):
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
@@ -518,6 +523,7 @@ class ScriptSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_vars(self, instance):
|
||||
@@ -525,6 +531,10 @@ class ScriptSerializer(serializers.Serializer):
|
||||
k: v.__class__.__name__ for k, v in instance._get_vars().items()
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ScriptDetailSerializer(ScriptSerializer):
|
||||
result = JobSerializer()
|
||||
|
||||
@@ -80,7 +80,7 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||
# Paginate data
|
||||
if page := self.paginate_queryset(choices):
|
||||
data = [
|
||||
{'value': c[0], 'label': c[1]} for c in page
|
||||
{'id': c[0], 'display': c[1]} for c in page
|
||||
]
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@@ -243,7 +243,7 @@ class ReportViewSet(ViewSet):
|
||||
'request': request,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response({'count': len(report_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
"""
|
||||
@@ -343,7 +343,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response({'count': len(script_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
module, script = self._get_script(pk)
|
||||
|
||||
@@ -19,6 +19,13 @@ WEBHOOK_EVENT_TYPES = {
|
||||
|
||||
# Dashboard
|
||||
DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.BookmarksWidget',
|
||||
'width': 4,
|
||||
'height': 5,
|
||||
'title': 'Bookmarks',
|
||||
'color': 'orange',
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
@@ -32,22 +39,6 @@ DEFAULT_DASHBOARD = [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'config': {
|
||||
'models': [
|
||||
'ipam.vrf',
|
||||
'ipam.aggregate',
|
||||
'ipam.prefix',
|
||||
'ipam.iprange',
|
||||
'ipam.ipaddress',
|
||||
'ipam.vlan',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.NoteWidget',
|
||||
'width': 4,
|
||||
@@ -65,13 +56,16 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'title': 'Circuits',
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'config': {
|
||||
'models': [
|
||||
'circuits.provider',
|
||||
'circuits.circuit',
|
||||
'circuits.providernetwork',
|
||||
'ipam.vrf',
|
||||
'ipam.aggregate',
|
||||
'ipam.prefix',
|
||||
'ipam.iprange',
|
||||
'ipam.ipaddress',
|
||||
'ipam.vlan',
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -86,6 +80,20 @@ DEFAULT_DASHBOARD = [
|
||||
'cache_timeout': 14400,
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'Circuits',
|
||||
'config': {
|
||||
'models': [
|
||||
'circuits.provider',
|
||||
'circuits.circuit',
|
||||
'circuits.providernetwork',
|
||||
'circuits.provideraccount',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import BookmarkOrderingChoices
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
@@ -115,6 +116,22 @@ class DashboardWidget:
|
||||
def name(self):
|
||||
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
|
||||
|
||||
@property
|
||||
def fg_color(self):
|
||||
"""
|
||||
Return the appropriate foreground (text) color for the widget's color.
|
||||
"""
|
||||
if self.color in (
|
||||
ButtonColorChoices.CYAN,
|
||||
ButtonColorChoices.GRAY,
|
||||
ButtonColorChoices.GREY,
|
||||
ButtonColorChoices.TEAL,
|
||||
ButtonColorChoices.WHITE,
|
||||
ButtonColorChoices.YELLOW,
|
||||
):
|
||||
return ButtonColorChoices.BLACK
|
||||
return ButtonColorChoices.WHITE
|
||||
|
||||
@property
|
||||
def form_data(self):
|
||||
return {
|
||||
@@ -346,13 +363,16 @@ class BookmarksWidget(DashboardWidget):
|
||||
def render(self, request):
|
||||
from extras.models import Bookmark
|
||||
|
||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||
if max_items := self.config.get('max_items'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
if request.user.is_anonymous:
|
||||
bookmarks = list()
|
||||
else:
|
||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||
if max_items := self.config.get('max_items'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'bookmarks': bookmarks,
|
||||
|
||||
@@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
|
||||
model = Tag
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
(_('Data'), ('data_source_id', 'data_file_id')),
|
||||
(_('Attributes'), ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
(_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
@@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Content types'),
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
required=False,
|
||||
label=_('Content types')
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
required=False,
|
||||
@@ -180,7 +180,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
content_type_id = ContentTypeChoiceField(
|
||||
label=_('Content type'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
|
||||
required=False
|
||||
)
|
||||
name = forms.CharField(
|
||||
|
||||
@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
@@ -75,13 +76,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'type': _(
|
||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below."
|
||||
)
|
||||
),
|
||||
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data
|
||||
# is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
@@ -89,6 +92,11 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -320,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||
required=False
|
||||
)
|
||||
tenant_groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Tenat groups'),
|
||||
label=_('Tenant groups'),
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
@@ -485,7 +493,9 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS',)),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
@@ -508,20 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
|
||||
if is_static:
|
||||
help_text += _(' (defined statically)')
|
||||
elif value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
self.fields[param.name].initial = value
|
||||
if is_static:
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
@@ -69,10 +69,7 @@ class Command(BaseCommand):
|
||||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
deleted_count = search_backend.clear()
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
|
||||
@@ -93,6 +93,8 @@ class ObjectChange(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-time']
|
||||
verbose_name = _('object change')
|
||||
verbose_name_plural = _('object changes')
|
||||
|
||||
def __str__(self):
|
||||
return '{} {} {} by {}'.format(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
@@ -8,6 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.jinja2 import ConfigTemplateLoader
|
||||
@@ -125,6 +127,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
verbose_name = _('config context')
|
||||
verbose_name_plural = _('config contexts')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -142,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
raise ValidationError(
|
||||
{'data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
def sync_data(self):
|
||||
@@ -198,7 +202,7 @@ class ConfigContextModel(models.Model):
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||
raise ValidationError(
|
||||
{'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
|
||||
@@ -233,6 +237,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('config template')
|
||||
verbose_name_plural = _('config templates')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -251,7 +257,19 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
"""
|
||||
Render the contents of the template.
|
||||
"""
|
||||
context = context or {}
|
||||
_context = dict()
|
||||
|
||||
# Populate the default template context with NetBox model classes, namespaced by app
|
||||
# TODO: Devise a canonical mechanism for identifying the models to include (see #13427)
|
||||
for app, model_names in registry['model_features']['custom_fields'].items():
|
||||
_context.setdefault(app, {})
|
||||
for model_name in model_names:
|
||||
model = apps.get_registered_model(app, model_name)
|
||||
_context[app][model.__name__] = model
|
||||
|
||||
# Add the provided context data, if any
|
||||
if context is not None:
|
||||
_context.update(context)
|
||||
|
||||
# Initialize the Jinja2 environment and instantiate the Template
|
||||
environment = self._get_environment()
|
||||
@@ -259,7 +277,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
template = environment.get_template(self.data_file.path)
|
||||
else:
|
||||
template = environment.from_string(self.template_code)
|
||||
output = template.render(**context)
|
||||
output = template.render(**_context)
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
return output.replace('\r\n', '\n')
|
||||
|
||||
@@ -28,6 +28,7 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
__all__ = (
|
||||
@@ -202,6 +203,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
verbose_name = _('custom field')
|
||||
verbose_name_plural = _('custom fields')
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
@@ -217,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
self._name = self.__dict__.get('name')
|
||||
|
||||
@property
|
||||
def search_type(self):
|
||||
@@ -280,7 +283,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
raise ValidationError({
|
||||
'default': _(
|
||||
'Invalid default value "{default}": {message}'
|
||||
).format(default=self.default, message=self.message)
|
||||
).format(default=self.default, message=err.message)
|
||||
})
|
||||
|
||||
# Minimum/maximum values can be set only for numeric fields
|
||||
@@ -315,14 +318,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
'choice_set': _("Choices may be set only on selection fields.")
|
||||
})
|
||||
|
||||
# A selection field's default (if any) must be present in its available choices
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
|
||||
raise ValidationError({
|
||||
'default': _(
|
||||
"The specified default value ({default}) is not listed as an available choice."
|
||||
).format(default=self.default)
|
||||
})
|
||||
|
||||
# Object fields must define an object_type; other fields must not
|
||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||
if not self.object_type:
|
||||
@@ -439,18 +434,25 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
if set_initial and default_choice:
|
||||
initial = default_choice
|
||||
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = CSVChoiceField if for_csv_import else DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
if for_csv_import:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = CSVChoiceField
|
||||
else:
|
||||
field_class = CSVMultipleChoiceField
|
||||
field = field_class(choices=choices, required=required, initial=initial)
|
||||
else:
|
||||
field_class = CSVMultipleChoiceField if for_csv_import else DynamicMultipleChoiceField
|
||||
widget_class = APISelectMultiple
|
||||
field = field_class(
|
||||
choices=choices,
|
||||
required=required,
|
||||
initial=initial,
|
||||
widget=widget_class(api_url=f'/api/extras/custom-field-choices/{self.choice_set.pk}/choices/')
|
||||
)
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
else:
|
||||
field_class = DynamicMultipleChoiceField
|
||||
widget_class = APISelectMultiple
|
||||
field = field_class(
|
||||
choices=choices,
|
||||
required=required,
|
||||
initial=initial,
|
||||
widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
|
||||
)
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
@@ -497,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
field.model = self
|
||||
field.label = str(self)
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
field.help_text = render_markdown(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
@@ -641,19 +643,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Validate selected choice
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
if value not in [c[0] for c in self.choices]:
|
||||
if value not in self.choice_set.values:
|
||||
raise ValidationError(
|
||||
_("Invalid choice ({value}). Available choices are: {choices}").format(
|
||||
value=value, choices=', '.join(self.choices)
|
||||
_("Invalid choice ({value}) for choice set {choiceset}.").format(
|
||||
value=value,
|
||||
choiceset=self.choice_set
|
||||
)
|
||||
)
|
||||
|
||||
# Validate all selected choices
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
if not set(value).issubset([c[0] for c in self.choices]):
|
||||
if not set(value).issubset(self.choice_set.values):
|
||||
raise ValidationError(
|
||||
_("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
|
||||
invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
|
||||
_("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
|
||||
value=value,
|
||||
choiceset=self.choice_set
|
||||
)
|
||||
)
|
||||
|
||||
# Validate selected object
|
||||
@@ -710,6 +715,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('custom field choice set')
|
||||
verbose_name_plural = _('custom field choice sets')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -736,6 +743,13 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
def choices_count(self):
|
||||
return len(self.choices)
|
||||
|
||||
@property
|
||||
def values(self):
|
||||
"""
|
||||
Returns an iterator of the valid choice values.
|
||||
"""
|
||||
return (x[0] for x in self.choices)
|
||||
|
||||
def clean(self):
|
||||
if not self.base_choices and not self.extra_choices:
|
||||
raise ValidationError(_("Must define base or extra choices."))
|
||||
|
||||
@@ -25,7 +25,8 @@ class Dashboard(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
pass
|
||||
verbose_name = _('dashboard')
|
||||
verbose_name_plural = _('dashboards')
|
||||
|
||||
def get_widget(self, id):
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -12,7 +11,7 @@ from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from extras.choices import *
|
||||
@@ -165,6 +164,8 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
name='%(app_label)s_%(class)s_unique_payload_url_types'
|
||||
),
|
||||
)
|
||||
verbose_name = _('webhook')
|
||||
verbose_name_plural = _('webhooks')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -284,6 +285,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
verbose_name = _('custom link')
|
||||
verbose_name_plural = _('custom links')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -312,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
@@ -371,6 +374,8 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('export template')
|
||||
verbose_name_plural = _('export templates')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -482,6 +487,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'name')
|
||||
verbose_name = _('saved filter')
|
||||
verbose_name_plural = _('saved filters')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -544,6 +551,8 @@ class ImageAttachment(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # name may be non-unique
|
||||
verbose_name = _('image attachment')
|
||||
verbose_name_plural = _('image attachments')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
@@ -622,7 +631,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created',)
|
||||
verbose_name_plural = 'journal entries'
|
||||
verbose_name = _('journal entry')
|
||||
verbose_name_plural = _('journal entries')
|
||||
|
||||
def __str__(self):
|
||||
created = timezone.localtime(self.created)
|
||||
@@ -677,6 +687,8 @@ class Bookmark(models.Model):
|
||||
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||
),
|
||||
)
|
||||
verbose_name = _('bookmark')
|
||||
verbose_name_plural = _('bookmarks')
|
||||
|
||||
def __str__(self):
|
||||
if self.object:
|
||||
@@ -707,9 +719,15 @@ class ConfigRevision(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
return f'Config revision #{self.pk} ({self.created})'
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
@@ -717,6 +735,8 @@ class ConfigRevision(models.Model):
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('extras:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
@@ -727,6 +747,6 @@ class ConfigRevision(models.Model):
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@admin.display(boolean=True)
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
@@ -42,6 +43,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _('report module')
|
||||
verbose_name_plural = _('report modules')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:report_list')
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
@@ -42,6 +43,8 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _('script module')
|
||||
verbose_name_plural = _('script modules')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:script_list')
|
||||
|
||||
@@ -51,6 +51,8 @@ class CachedValue(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'object_type', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
||||
@@ -41,6 +41,8 @@ class Branch(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('branch')
|
||||
verbose_name_plural = _('branches')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.pk})'
|
||||
@@ -89,6 +91,8 @@ class StagedChange(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
verbose_name = _('staged change')
|
||||
verbose_name_plural = _('staged changes')
|
||||
|
||||
def __str__(self):
|
||||
action = self.get_action_display()
|
||||
|
||||
@@ -50,6 +50,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('tag')
|
||||
verbose_name_plural = _('tags')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tag', args=[self.pk])
|
||||
@@ -75,3 +77,5 @@ class TaggedItem(GenericTaggedItemBase):
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=["content_type", "object_id"])]
|
||||
verbose_name = _('tagged item')
|
||||
verbose_name_plural = _('tagged items')
|
||||
|
||||
@@ -2,7 +2,6 @@ import collections
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
from packaging import version
|
||||
@@ -12,6 +11,7 @@ from netbox.search import register_search
|
||||
from .navigation import *
|
||||
from .registration import *
|
||||
from .templates import *
|
||||
from .utils import *
|
||||
|
||||
# Initialize plugin registry
|
||||
registry['plugins'].update({
|
||||
@@ -146,23 +146,3 @@ class PluginConfig(AppConfig):
|
||||
for setting, value in cls.default_settings.items():
|
||||
if setting not in user_config:
|
||||
user_config[setting] = value
|
||||
|
||||
|
||||
#
|
||||
# Utilities
|
||||
#
|
||||
|
||||
def get_plugin_config(plugin_name, parameter, default=None):
|
||||
"""
|
||||
Return the value of the specified plugin configuration parameter.
|
||||
|
||||
Args:
|
||||
plugin_name: The name of the plugin
|
||||
parameter: The name of the configuration parameter
|
||||
default: The value to return if the parameter is not defined (default: None)
|
||||
"""
|
||||
try:
|
||||
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
||||
return plugin_config.get(parameter, default)
|
||||
except KeyError:
|
||||
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
||||
|
||||
@@ -36,9 +36,10 @@ class PluginMenuItem:
|
||||
permissions = []
|
||||
buttons = []
|
||||
|
||||
def __init__(self, link, link_text, permissions=None, buttons=None):
|
||||
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
|
||||
self.link = link
|
||||
self.link_text = link_text
|
||||
self.staff_only = staff_only
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||
|
||||
37
netbox/extras/plugins/utils.py
Normal file
37
netbox/extras/plugins/utils.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
__all__ = (
|
||||
'get_installed_plugins',
|
||||
'get_plugin_config',
|
||||
)
|
||||
|
||||
|
||||
def get_installed_plugins():
|
||||
"""
|
||||
Return a dictionary mapping the names of installed plugins to their versions.
|
||||
"""
|
||||
plugins = {}
|
||||
for plugin_name in settings.PLUGINS:
|
||||
plugin_name = plugin_name.rsplit('.', 1)[-1]
|
||||
plugin_config = apps.get_app_config(plugin_name)
|
||||
plugins[plugin_name] = getattr(plugin_config, 'version', None)
|
||||
|
||||
return dict(sorted(plugins.items()))
|
||||
|
||||
|
||||
def get_plugin_config(plugin_name, parameter, default=None):
|
||||
"""
|
||||
Return the value of the specified plugin configuration parameter.
|
||||
|
||||
Args:
|
||||
plugin_name: The name of the plugin
|
||||
parameter: The name of the configuration parameter
|
||||
default: The value to return if the parameter is not defined (default: None)
|
||||
"""
|
||||
try:
|
||||
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
||||
return plugin_config.get(parameter, default)
|
||||
except KeyError:
|
||||
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
||||
@@ -214,20 +214,18 @@ class Report(object):
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
job.data = self._results
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job.status = JobStatusChoices.STATUS_FAILED
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job.status = JobStatusChoices.STATUS_COMPLETED
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
finally:
|
||||
job.data = self._results
|
||||
job.terminate()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
||||
@@ -401,23 +401,23 @@ class BaseScript:
|
||||
|
||||
def log_debug(self, message):
|
||||
self.logger.log(logging.DEBUG, message)
|
||||
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
|
||||
self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
|
||||
|
||||
def log_success(self, message):
|
||||
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
|
||||
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
|
||||
self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
|
||||
|
||||
def log_info(self, message):
|
||||
self.logger.log(logging.INFO, message)
|
||||
self.log.append((LogLevelChoices.LOG_INFO, message))
|
||||
self.log.append((LogLevelChoices.LOG_INFO, str(message)))
|
||||
|
||||
def log_warning(self, message):
|
||||
self.logger.log(logging.WARNING, message)
|
||||
self.log.append((LogLevelChoices.LOG_WARNING, message))
|
||||
self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
|
||||
|
||||
def log_failure(self, message):
|
||||
self.logger.log(logging.ERROR, message)
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, message))
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
|
||||
|
||||
# Convenience functions
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
from ipam.models import VLAN
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import APITestCase, TestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
@@ -427,6 +428,97 @@ class CustomFieldTest(TestCase):
|
||||
self.assertNotIn('field1', site.custom_field_data)
|
||||
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
|
||||
|
||||
def test_default_value_validation(self):
|
||||
choiceset = CustomFieldChoiceSet.objects.create(
|
||||
name="Test Choice Set",
|
||||
extra_choices=(
|
||||
('choice1', 'Choice 1'),
|
||||
('choice2', 'Choice 2'),
|
||||
)
|
||||
)
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
object_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Text
|
||||
CustomField(name='test', type='text', required=True, default="Default text").full_clean()
|
||||
|
||||
# Integer
|
||||
CustomField(name='test', type='integer', required=True, default=1).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='integer', required=True, default='xxx').full_clean()
|
||||
|
||||
# Boolean
|
||||
CustomField(name='test', type='boolean', required=True, default=True).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='boolean', required=True, default='xxx').full_clean()
|
||||
|
||||
# Date
|
||||
CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='date', required=True, default='xxx').full_clean()
|
||||
|
||||
# Datetime
|
||||
CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='datetime', required=True, default='xxx').full_clean()
|
||||
|
||||
# URL
|
||||
CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean()
|
||||
|
||||
# JSON
|
||||
CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean()
|
||||
|
||||
# Selection
|
||||
CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean()
|
||||
|
||||
# Multi-select
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiselect',
|
||||
required=True,
|
||||
choice_set=choiceset,
|
||||
default=['choice1'] # Single default choice
|
||||
).full_clean()
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiselect',
|
||||
required=True,
|
||||
choice_set=choiceset,
|
||||
default=['choice1', 'choice2'] # Multiple default choices
|
||||
).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiselect',
|
||||
required=True,
|
||||
choice_set=choiceset,
|
||||
default=['xxx']
|
||||
).full_clean()
|
||||
|
||||
# Object
|
||||
CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean()
|
||||
|
||||
# Multi-object
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiobject',
|
||||
required=True,
|
||||
object_type=object_type,
|
||||
default=[site.pk]
|
||||
).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiobject',
|
||||
required=True,
|
||||
object_type=object_type,
|
||||
default=["xxx"]
|
||||
).full_clean()
|
||||
|
||||
|
||||
class CustomFieldManagerTest(TestCase):
|
||||
|
||||
@@ -1085,7 +1177,11 @@ class CustomFieldImportTest(TestCase):
|
||||
)
|
||||
csv_data = '\n'.join(','.join(row) for row in data)
|
||||
|
||||
response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'})
|
||||
response = self.client.post(reverse('dcim:site_import'), {
|
||||
'data': csv_data,
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(Site.objects.count(), 3)
|
||||
|
||||
|
||||
@@ -1109,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
# Simulate *creation* changelog records for two of the sites
|
||||
request_id = uuid.uuid4()
|
||||
cls.create_request_id = request_id
|
||||
objectchanges = (
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
@@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
||||
|
||||
# Simulate *update* changelog records for two of the sites
|
||||
request_id = uuid.uuid4()
|
||||
cls.update_request_id = request_id
|
||||
objectchanges = (
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
@@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
||||
)
|
||||
ObjectChange.objects.bulk_create(objectchanges)
|
||||
|
||||
# Simulate *create* and *update* changelog records for two of the sites
|
||||
request_id = uuid.uuid4()
|
||||
cls.create_update_request_id = request_id
|
||||
objectchanges = (
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
changed_object_id=sites[2].pk,
|
||||
action=ObjectChangeActionChoices.ACTION_CREATE,
|
||||
request_id=request_id
|
||||
),
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
changed_object_id=sites[3].pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
request_id=request_id
|
||||
),
|
||||
)
|
||||
ObjectChange.objects.bulk_create(objectchanges)
|
||||
|
||||
def test_created_by_request(self):
|
||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id
|
||||
params = {'created_by_request': request_id}
|
||||
params = {'created_by_request': self.create_request_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.queryset.count(), 3)
|
||||
self.assertEqual(self.queryset.count(), 4)
|
||||
|
||||
def test_updated_by_request(self):
|
||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id
|
||||
params = {'updated_by_request': request_id}
|
||||
params = {'updated_by_request': self.update_request_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.queryset.count(), 3)
|
||||
self.assertEqual(self.queryset.count(), 4)
|
||||
|
||||
def test_modified_by_request(self):
|
||||
params = {'modified_by_request': self.create_update_request_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.queryset.count(), 4)
|
||||
|
||||
@@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.plugins import PluginMenu, get_plugin_config
|
||||
from extras.plugins import PluginMenu
|
||||
from extras.tests.dummy_plugin import config as dummy_config
|
||||
from extras.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.models import DeviceType, Manufacturer, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
@@ -434,7 +434,8 @@ class ConfigContextTestCase(
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Create three ConfigContexts
|
||||
for i in range(1, 4):
|
||||
@@ -443,7 +444,7 @@ class ConfigContextTestCase(
|
||||
data={'foo': i}
|
||||
)
|
||||
configcontext.save()
|
||||
configcontext.sites.add(site)
|
||||
configcontext.device_types.add(devicetype)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Config Context X',
|
||||
@@ -451,11 +452,12 @@ class ConfigContextTestCase(
|
||||
'description': 'A new config context',
|
||||
'is_active': True,
|
||||
'regions': [],
|
||||
'sites': [site.pk],
|
||||
'sites': [],
|
||||
'roles': [],
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
DUMMY_URL = "http://localhost/"
|
||||
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
|
||||
DUMMY_URL = 'http://localhost:9000/'
|
||||
DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
|
||||
|
||||
webhooks = Webhook.objects.bulk_create((
|
||||
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
|
||||
@@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
|
||||
name='Conditional Webhook',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
payload_url='http://localhost/',
|
||||
payload_url='http://localhost:9000/',
|
||||
conditions={
|
||||
'and': [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -18,6 +19,7 @@ from netbox.config import get_config, PARAMS
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.rqworker import get_workers_for_queue
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
@@ -44,6 +46,21 @@ class CustomFieldListView(generic.ObjectListView):
|
||||
class CustomFieldView(generic.ObjectView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = ()
|
||||
|
||||
for content_type in instance.content_types.all():
|
||||
related_models += (
|
||||
content_type.model_class().objects.restrict(request.user, 'view').exclude(
|
||||
Q(**{f'custom_field_data__{instance.name}': ''}) |
|
||||
Q(**{f'custom_field_data__{instance.name}': None})
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'edit')
|
||||
class CustomFieldEditView(generic.ObjectEditView):
|
||||
@@ -89,6 +106,25 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
|
||||
class CustomFieldChoiceSetView(generic.ObjectView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# Paginate choices list
|
||||
per_page = get_paginate_count(request)
|
||||
try:
|
||||
page_number = request.GET.get('page', 1)
|
||||
except ValueError:
|
||||
page_number = 1
|
||||
paginator = EnhancedPaginator(instance.choices, per_page)
|
||||
try:
|
||||
choices = paginator.page(page_number)
|
||||
except EmptyPage:
|
||||
choices = paginator.page(paginator.num_pages)
|
||||
|
||||
return {
|
||||
'paginator': paginator,
|
||||
'choices': choices,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(CustomFieldChoiceSet, 'edit')
|
||||
class CustomFieldChoiceSetEditView(generic.ObjectEditView):
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from ipam import models
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
__all__ = [
|
||||
__all__ = (
|
||||
'IPAddressField',
|
||||
]
|
||||
'IPNetworkField',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# IP address field
|
||||
#
|
||||
|
||||
class IPAddressField(serializers.CharField):
|
||||
"""IPAddressField with mask"""
|
||||
|
||||
"""
|
||||
An IPv4 or IPv6 address with optional mask
|
||||
"""
|
||||
default_error_messages = {
|
||||
'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'),
|
||||
}
|
||||
@@ -24,7 +21,27 @@ class IPAddressField(serializers.CharField):
|
||||
try:
|
||||
return IPNetwork(data)
|
||||
except AddrFormatError:
|
||||
raise serializers.ValidationError("Invalid IP address format: {}".format(data))
|
||||
raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise serializers.ValidationError(e)
|
||||
|
||||
def to_representation(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class IPNetworkField(serializers.CharField):
|
||||
"""
|
||||
An IPv4 or IPv6 prefix
|
||||
"""
|
||||
default_error_messages = {
|
||||
'invalid': _('Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation.'),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
return IPNetwork(data)
|
||||
except AddrFormatError:
|
||||
raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise serializers.ValidationError(e)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
from .nested_serializers import *
|
||||
from .field_serializers import IPAddressField
|
||||
from .field_serializers import IPAddressField, IPNetworkField
|
||||
|
||||
|
||||
#
|
||||
@@ -138,7 +138,7 @@ class AggregateSerializer(NetBoxModelSerializer):
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
rir = NestedRIRSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
prefix = serializers.CharField()
|
||||
prefix = IPNetworkField()
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
@@ -146,7 +146,6 @@ class AggregateSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
|
||||
#
|
||||
@@ -306,7 +305,7 @@ class PrefixSerializer(NetBoxModelSerializer):
|
||||
role = NestedRoleSerializer(required=False, allow_null=True)
|
||||
children = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(read_only=True)
|
||||
prefix = serializers.CharField()
|
||||
prefix = IPNetworkField()
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
@@ -315,7 +314,6 @@ class PrefixSerializer(NetBoxModelSerializer):
|
||||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
|
||||
'_depth',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
|
||||
class PrefixLengthSerializer(serializers.Serializer):
|
||||
@@ -386,7 +384,6 @@ class IPRangeSerializer(NetBoxModelSerializer):
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Round
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
@@ -16,6 +14,7 @@ from circuits.models import Provider
|
||||
from dcim.models import Site
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
from ipam.models import L2VPN, L2VPNTermination
|
||||
from ipam.utils import get_next_available_prefix
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
@@ -24,7 +23,6 @@ from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
from ipam.models import L2VPN, L2VPNTermination
|
||||
|
||||
|
||||
class IPAMRootView(APIRootView):
|
||||
@@ -346,7 +344,11 @@ class AvailableASNsView(AvailableObjectsView):
|
||||
def get(self, request, pk):
|
||||
return super().get(request, pk)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)})
|
||||
@extend_schema(
|
||||
methods=["post"],
|
||||
responses={201: serializers.ASNSerializer(many=True)},
|
||||
request=serializers.ASNSerializer(many=True),
|
||||
)
|
||||
def post(self, request, pk):
|
||||
return super().post(request, pk)
|
||||
|
||||
@@ -395,7 +397,11 @@ class AvailablePrefixesView(AvailableObjectsView):
|
||||
def get(self, request, pk):
|
||||
return super().get(request, pk)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
|
||||
@extend_schema(
|
||||
methods=["post"],
|
||||
responses={201: serializers.PrefixSerializer(many=True)},
|
||||
request=serializers.PrefixSerializer(many=True),
|
||||
)
|
||||
def post(self, request, pk):
|
||||
return super().post(request, pk)
|
||||
|
||||
@@ -435,7 +441,11 @@ class AvailableIPAddressesView(AvailableObjectsView):
|
||||
def get(self, request, pk):
|
||||
return super().get(request, pk)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
||||
@extend_schema(
|
||||
methods=["post"],
|
||||
responses={201: serializers.IPAddressSerializer(many=True)},
|
||||
request=serializers.IPAddressSerializer(many=True),
|
||||
)
|
||||
def post(self, request, pk):
|
||||
return super().post(request, pk)
|
||||
|
||||
@@ -482,6 +492,10 @@ class AvailableVLANsView(AvailableObjectsView):
|
||||
def get(self, request, pk):
|
||||
return super().get(request, pk)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
||||
@extend_schema(
|
||||
methods=["post"],
|
||||
responses={201: serializers.VLANSerializer(many=True)},
|
||||
request=serializers.VLANSerializer(many=True),
|
||||
)
|
||||
def post(self, request, pk):
|
||||
return super().post(request, pk)
|
||||
|
||||
@@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
choices=IPRangeStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
parent = MultiValueCharFilter(
|
||||
method='search_by_parent',
|
||||
label=_('Parent prefix'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
@@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def search_by_parent(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
q = Q()
|
||||
for prefix in value:
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
|
||||
q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
@@ -591,6 +607,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='_assigned_to_interface',
|
||||
label=_('Is assigned to an interface'),
|
||||
)
|
||||
assigned = django_filters.BooleanFilter(
|
||||
method='_assigned',
|
||||
label=_('Is assigned'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=IPAddressStatusChoices,
|
||||
null_value=None
|
||||
@@ -706,6 +726,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
assigned_object_id__isnull=False
|
||||
)
|
||||
|
||||
def _assigned(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.exclude(
|
||||
assigned_object_type__isnull=True,
|
||||
assigned_object_id__isnull=True
|
||||
)
|
||||
else:
|
||||
return queryset.filter(
|
||||
assigned_object_type__isnull=True,
|
||||
assigned_object_id__isnull=True
|
||||
)
|
||||
|
||||
|
||||
class FHRPGroupFilterSet(NetBoxModelFilterSet):
|
||||
protocol = django_filters.MultipleChoiceFilter(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Device, Interface, Site
|
||||
@@ -10,7 +9,9 @@ from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
|
||||
from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
|
||||
)
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
|
||||
__all__ = (
|
||||
@@ -42,10 +43,25 @@ class VRFImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
import_targets = CSVModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Import route targets')
|
||||
)
|
||||
export_targets = CSVModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Export route targets')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
|
||||
'tags',
|
||||
)
|
||||
|
||||
|
||||
class RouteTargetImportForm(NetBoxModelImportForm):
|
||||
@@ -576,9 +592,11 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
||||
|
||||
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
|
||||
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
|
||||
if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
|
||||
if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
|
||||
raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
|
||||
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
|
||||
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
||||
# if this is an update we might not have interface or vlan in the form data
|
||||
if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
||||
|
||||
@@ -256,7 +256,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attriubtes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
|
||||
(_('Attributes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user