mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-10 22:02:17 -06:00
Compare commits
572 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6159994552 | ||
|
|
398041c607 | ||
|
|
6ce9f8f291 | ||
|
|
c2c8a139f3 | ||
|
|
698c0decb4 | ||
|
|
ef61c70a9d | ||
|
|
97863115ba | ||
|
|
fa5493a5d8 | ||
|
|
3e9cec3e8e | ||
|
|
943ec0b64b | ||
|
|
8008015082 | ||
|
|
af54d96d30 | ||
|
|
d98aa03e9d | ||
|
|
8d4c686ae2 | ||
|
|
982b9454f8 | ||
|
|
28a2a37ed2 | ||
|
|
3f019732b3 | ||
|
|
007852a48f | ||
|
|
3474697a66 | ||
|
|
4e09b32dd9 | ||
|
|
6dde0f030a | ||
|
|
d154b4cc9e | ||
|
|
7c11fa7b50 | ||
|
|
264bf6c484 | ||
|
|
3854a9d633 | ||
|
|
8bad3aee74 | ||
|
|
a1f624c1cc | ||
|
|
ff0a0df478 | ||
|
|
5dd2f37035 | ||
|
|
862e44e96f | ||
|
|
643b0eaf65 | ||
|
|
0af6df3121 | ||
|
|
e0616d933f | ||
|
|
1e7fdbc79a | ||
|
|
1473d90243 | ||
|
|
32eee0bede | ||
|
|
131436fc20 | ||
|
|
966c188977 | ||
|
|
afba80bff9 | ||
|
|
0d267d97fe | ||
|
|
b0cd372af9 | ||
|
|
e5af4f6f17 | ||
|
|
399a633d9d | ||
|
|
2ef223b5ea | ||
|
|
2cdb527df9 | ||
|
|
fc0e8e2aae | ||
|
|
e5454d6714 | ||
|
|
328958876a | ||
|
|
a7389de109 | ||
|
|
b911ab01d2 | ||
|
|
9153c71cbf | ||
|
|
b44aa9d32e | ||
|
|
bcb1d9af0b | ||
|
|
ef84889a57 | ||
|
|
81c027e7cf | ||
|
|
fd62a248ee | ||
|
|
2c8bea1b59 | ||
|
|
07364abf9e | ||
|
|
20cb13e1bb | ||
|
|
3f3b385de7 | ||
|
|
94b12e506e | ||
|
|
4ec6e52e73 | ||
|
|
88adc5ca86 | ||
|
|
68f73c7f94 | ||
|
|
223c95adbc | ||
|
|
3aaca1ca02 | ||
|
|
6a4d17b8a5 | ||
|
|
720c5fabaf | ||
|
|
1c5239a4d0 | ||
|
|
05b5609d86 | ||
|
|
7e92aeb7ac | ||
|
|
6e2eb15a80 | ||
|
|
0b825ac3d0 | ||
|
|
b5f1d74d6f | ||
|
|
e071b7dfd5 | ||
|
|
53e4e74930 | ||
|
|
b83de7eb11 | ||
|
|
38a208242b | ||
|
|
4acd8e180d | ||
|
|
debc8521a5 | ||
|
|
8bd268d81c | ||
|
|
ae6848b194 | ||
|
|
b22744b031 | ||
|
|
a75d7079df | ||
|
|
aa8442a345 | ||
|
|
70625a5cb0 | ||
|
|
7c043d9b4f | ||
|
|
546f17ab50 | ||
|
|
1c9986efc4 | ||
|
|
8ae13e29f5 | ||
|
|
f5bb072f28 | ||
|
|
37eef0ba6d | ||
|
|
603b80db1b | ||
|
|
8d9543cb6a | ||
|
|
c823660a8f | ||
|
|
ec4d28ac6c | ||
|
|
0c5ad85b35 | ||
|
|
bdecf7a3e3 | ||
|
|
6b62720daf | ||
|
|
d48c450018 | ||
|
|
078404fb59 | ||
|
|
4bb526896f | ||
|
|
0476006ef2 | ||
|
|
19831f0177 | ||
|
|
fc9871fba3 | ||
|
|
b34f4f8e43 | ||
|
|
0357d8522c | ||
|
|
08d06bd781 | ||
|
|
01a97add2a | ||
|
|
3cb351dceb | ||
|
|
9e11591b3b | ||
|
|
e4c1cece75 | ||
|
|
6881a98048 | ||
|
|
36de9f10d6 | ||
|
|
1cc135f01f | ||
|
|
079c8894fa | ||
|
|
957074a134 | ||
|
|
970759ed8b | ||
|
|
22f17a1424 | ||
|
|
5ed797cfc9 | ||
|
|
8ad59058a5 | ||
|
|
ec7bbcf90d | ||
|
|
37dde72c8f | ||
|
|
972f9be291 | ||
|
|
8b33b888b2 | ||
|
|
d29fd338eb | ||
|
|
c4f7e8121a | ||
|
|
8b5dba25f5 | ||
|
|
e18b5f5fd4 | ||
|
|
a5dc9537e5 | ||
|
|
3064948d8c | ||
|
|
e6bcc4a3fe | ||
|
|
6967b6bdc5 | ||
|
|
a8977a5dec | ||
|
|
b837e8ea0b | ||
|
|
110052fa0f | ||
|
|
84bb977d2e | ||
|
|
2d93c2b2da | ||
|
|
9e4f2a9614 | ||
|
|
5412a9f8ea | ||
|
|
d7177d3e05 | ||
|
|
a21bd81681 | ||
|
|
e653f35bf1 | ||
|
|
28ea06a8bc | ||
|
|
86b0491b68 | ||
|
|
c8309581be | ||
|
|
376c531fe4 | ||
|
|
b2c5bcd4f1 | ||
|
|
73c64272d8 | ||
|
|
11fe54753e | ||
|
|
69f921aea9 | ||
|
|
594ef71027 | ||
|
|
d25d8c21f6 | ||
|
|
835d13542f | ||
|
|
7f5a3fffd3 | ||
|
|
1890e710cb | ||
|
|
a9fefbec5c | ||
|
|
b96e3af6c7 | ||
|
|
12e6fe1d50 | ||
|
|
60c03a646c | ||
|
|
59dcbce417 | ||
|
|
df10fa87d3 | ||
|
|
a954406d1f | ||
|
|
e2213f458f | ||
|
|
55adcc1f0c | ||
|
|
d6eaa3d0cc | ||
|
|
25ad58d42c | ||
|
|
b61bccbb67 | ||
|
|
f1da517c84 | ||
|
|
a4019be28c | ||
|
|
36090d9f02 | ||
|
|
6b101d2c49 | ||
|
|
6436d703f5 | ||
|
|
b3243704df | ||
|
|
8bedfcfc64 | ||
|
|
e0aa2c33e9 | ||
|
|
49f268a14c | ||
|
|
2bb0e65aea | ||
|
|
8b6d731cb6 | ||
|
|
1cd629efb3 | ||
|
|
2f7f5425d8 | ||
|
|
215156c333 | ||
|
|
a5d2055c11 | ||
|
|
ffc2c564b8 | ||
|
|
16f222b0ab | ||
|
|
3edf90714a | ||
|
|
4e8fc03c2b | ||
|
|
5037283b62 | ||
|
|
f2c9135b96 | ||
|
|
7378d82bc4 | ||
|
|
ed10a99771 | ||
|
|
4df128d34e | ||
|
|
33d0db5854 | ||
|
|
4a57a554da | ||
|
|
21fe7c57d8 | ||
|
|
3bcc1429dd | ||
|
|
9a6a479452 | ||
|
|
c43487b741 | ||
|
|
6b50755a5a | ||
|
|
53998e0fff | ||
|
|
7341ae087c | ||
|
|
9ea8dca4e3 | ||
|
|
d80e64b2cc | ||
|
|
d7354f4dab | ||
|
|
8d1676db54 | ||
|
|
0714a40509 | ||
|
|
5262156e1a | ||
|
|
7ac27b59c6 | ||
|
|
fc7a43f23e | ||
|
|
d5ecfe7bef | ||
|
|
e58d1ac87e | ||
|
|
bb653e733c | ||
|
|
9c27d18d6c | ||
|
|
e5c13d2d72 | ||
|
|
b27529d927 | ||
|
|
95257114df | ||
|
|
935da0d51f | ||
|
|
78ed85943b | ||
|
|
7d87cc498a | ||
|
|
a523d25c0d | ||
|
|
d84e5d1839 | ||
|
|
063e79451f | ||
|
|
b4a842d9da | ||
|
|
ec0cb7a8bc | ||
|
|
841471104b | ||
|
|
ac71416eb9 | ||
|
|
779d685335 | ||
|
|
4d1e798c56 | ||
|
|
a598035236 | ||
|
|
50395aa821 | ||
|
|
6d9c8fd85b | ||
|
|
b65d994397 | ||
|
|
b20258c66e | ||
|
|
9984238f2a | ||
|
|
c3599bacf2 | ||
|
|
c10481b99d | ||
|
|
02e01b7386 | ||
|
|
ca7147a0a7 | ||
|
|
022c360964 | ||
|
|
d41f4d2db3 | ||
|
|
4871682dc6 | ||
|
|
1cebc1248b | ||
|
|
70d235f99e | ||
|
|
153409d37e | ||
|
|
67a30fdf91 | ||
|
|
911ce3f047 | ||
|
|
c97f7041a7 | ||
|
|
89bfb4f722 | ||
|
|
da3935ff36 | ||
|
|
06810bff91 | ||
|
|
a9af75bbd1 | ||
|
|
da2bff691b | ||
|
|
a85b3aa69f | ||
|
|
859f89101e | ||
|
|
2545912532 | ||
|
|
2fc1519bc6 | ||
|
|
be6ef15ffa | ||
|
|
e98f0c39d1 | ||
|
|
5666079d92 | ||
|
|
85f5ba9a25 | ||
|
|
df141a48d9 | ||
|
|
fed6fc131b | ||
|
|
cf49891853 | ||
|
|
de2a894269 | ||
|
|
34d10f8db7 | ||
|
|
68f76465cf | ||
|
|
5f91413023 | ||
|
|
45d6955260 | ||
|
|
3b801d43bc | ||
|
|
30df060357 | ||
|
|
252be84bf0 | ||
|
|
40ab272995 | ||
|
|
0ec3b5db8b | ||
|
|
55e07c1c9a | ||
|
|
7e475511b6 | ||
|
|
ca77e4545a | ||
|
|
5dc9723585 | ||
|
|
50a451eddc | ||
|
|
3f8350b78f | ||
|
|
500a56b869 | ||
|
|
e50b7174bf | ||
|
|
8299c735b1 | ||
|
|
124878ed22 | ||
|
|
1c09570805 | ||
|
|
e56797737d | ||
|
|
81852de1fa | ||
|
|
fbd39da8ca | ||
|
|
db0ef95fe3 | ||
|
|
d888aa67f9 | ||
|
|
0cb3e1749b | ||
|
|
b5a51aced3 | ||
|
|
04ba57cb38 | ||
|
|
ba42ad2115 | ||
|
|
5c13382071 | ||
|
|
3df8c63d5c | ||
|
|
8ff10d5995 | ||
|
|
a5a7358d26 | ||
|
|
63ac8863f3 | ||
|
|
2047a16a57 | ||
|
|
8d6d55d628 | ||
|
|
9a7dd5ea19 | ||
|
|
30b544a743 | ||
|
|
a0bb7b08bd | ||
|
|
e1d655cb23 | ||
|
|
5d46a112f8 | ||
|
|
a1b1e261de | ||
|
|
e01e5e6b0e | ||
|
|
4f2dc50b5c | ||
|
|
c3e5106b04 | ||
|
|
593ae295e3 | ||
|
|
9d50b78b69 | ||
|
|
198170ca48 | ||
|
|
00986fd7bf | ||
|
|
2519ebff9d | ||
|
|
c33775d71e | ||
|
|
6b0721cc21 | ||
|
|
d306e76420 | ||
|
|
73cd76932a | ||
|
|
5d19a9f50f | ||
|
|
368c30ef9d | ||
|
|
f77bf72de8 | ||
|
|
f2fbd92f78 | ||
|
|
480134302f | ||
|
|
74cc8c022c | ||
|
|
c6f3b00f0e | ||
|
|
626fbd1d10 | ||
|
|
b8df05cf88 | ||
|
|
57973f62c5 | ||
|
|
e56fc4b1ee | ||
|
|
f9452163c5 | ||
|
|
76ebd2d34f | ||
|
|
85c273c8ca | ||
|
|
b9cd834e95 | ||
|
|
3067c3f262 | ||
|
|
cfa6bee081 | ||
|
|
b46cc2c1a9 | ||
|
|
5e734fc5a6 | ||
|
|
d08bc7767e | ||
|
|
bbd0761887 | ||
|
|
4668149943 | ||
|
|
a5b7c057eb | ||
|
|
0a04bb110a | ||
|
|
9c0b414676 | ||
|
|
5be4b0c4fd | ||
|
|
8e0eab20e2 | ||
|
|
1b5aa67f5d | ||
|
|
02c278f393 | ||
|
|
e57b8aa26f | ||
|
|
3d023126ba | ||
|
|
53f58d4496 | ||
|
|
1a6ee237f6 | ||
|
|
33a99441a4 | ||
|
|
3df7e283e3 | ||
|
|
b295849f53 | ||
|
|
c107f35118 | ||
|
|
3d91153275 | ||
|
|
54472b3806 | ||
|
|
14e5f89feb | ||
|
|
a0b93bb4df | ||
|
|
85347d9675 | ||
|
|
060f7a7191 | ||
|
|
81ca6f7cba | ||
|
|
515645bb4d | ||
|
|
6ae6209457 | ||
|
|
272325ff05 | ||
|
|
a84b49b92d | ||
|
|
b63efdd80b | ||
|
|
7a64404299 | ||
|
|
2afa6ed2cb | ||
|
|
34f1a9ebfb | ||
|
|
6f2f8697ae | ||
|
|
6ec9d1d6ce | ||
|
|
047f22e110 | ||
|
|
a91fcbb310 | ||
|
|
5fc3eac0f6 | ||
|
|
937faaf149 | ||
|
|
115e7d6e50 | ||
|
|
34259d5d9d | ||
|
|
91b6ebb0c0 | ||
|
|
60b4f1f89f | ||
|
|
023ff6834a | ||
|
|
d00cab0b0a | ||
|
|
17493ff655 | ||
|
|
6c27e6c4fe | ||
|
|
4cb0be4df3 | ||
|
|
40f555a3b6 | ||
|
|
881fdbe893 | ||
|
|
2bda399982 | ||
|
|
75d840fa1a | ||
|
|
800bdd8fc5 | ||
|
|
b04ade8060 | ||
|
|
7f4d96f33e | ||
|
|
74731bc6ae | ||
|
|
9d8daca54d | ||
|
|
bb898b719f | ||
|
|
3a0b57b50f | ||
|
|
38d2333165 | ||
|
|
a7f0b5adb3 | ||
|
|
109fff0fa6 | ||
|
|
8c591e7248 | ||
|
|
0dafa10e27 | ||
|
|
60e3ff0bf0 | ||
|
|
6052405eb7 | ||
|
|
17a45109f4 | ||
|
|
392b45e6cb | ||
|
|
f27e1ba885 | ||
|
|
990c9d6f7c | ||
|
|
b301b8f6f2 | ||
|
|
924cf9bfd4 | ||
|
|
7e6573d191 | ||
|
|
fdae3a3f31 | ||
|
|
51194e20f2 | ||
|
|
043f2cb214 | ||
|
|
651d462456 | ||
|
|
7cb287d6c6 | ||
|
|
d16d89028f | ||
|
|
baa277c592 | ||
|
|
007f930fed | ||
|
|
f253f164a3 | ||
|
|
abfe71bb04 | ||
|
|
0c645b12d1 | ||
|
|
f824d1eb3b | ||
|
|
afbbe1148f | ||
|
|
8403e91fc9 | ||
|
|
c300879995 | ||
|
|
ea1d298eb3 | ||
|
|
d17b36519a | ||
|
|
f4f0896470 | ||
|
|
2c9b41db75 | ||
|
|
a38cd449c5 | ||
|
|
977cad3830 | ||
|
|
987587b5f3 | ||
|
|
9351f686b1 | ||
|
|
42e557bd3f | ||
|
|
35c2c8e8de | ||
|
|
960e1d49c2 | ||
|
|
100d979d3e | ||
|
|
b734599f40 | ||
|
|
b41b4fc584 | ||
|
|
b69bf58c63 | ||
|
|
a73e598a6e | ||
|
|
af2de6776d | ||
|
|
98195c9e74 | ||
|
|
97188ad85b | ||
|
|
9c673d2a2e | ||
|
|
77182d8711 | ||
|
|
a01c9ff379 | ||
|
|
32399b0cb5 | ||
|
|
fe3cdb1e20 | ||
|
|
3d25cecc69 | ||
|
|
068c0ff36c | ||
|
|
86a298792a | ||
|
|
a6599874db | ||
|
|
0deae84ecb | ||
|
|
6243fbfd0d | ||
|
|
f49d7ce1da | ||
|
|
75fed52bb6 | ||
|
|
90ec177360 | ||
|
|
667eadb430 | ||
|
|
afbe0bc307 | ||
|
|
c65af6a74f | ||
|
|
669aee2d73 | ||
|
|
67f0dfa449 | ||
|
|
e630a1ace1 | ||
|
|
21485ca6e2 | ||
|
|
87fb4af5cb | ||
|
|
e86ec75513 | ||
|
|
1ad099d9fd | ||
|
|
f9a677c1a3 | ||
|
|
6c6b67330f | ||
|
|
2b33e78fd3 | ||
|
|
b65e9fe0f5 | ||
|
|
571b817f04 | ||
|
|
e2b2815545 | ||
|
|
0ea58ab268 | ||
|
|
2fbb39bf6f | ||
|
|
f4c87b3739 | ||
|
|
696d91daa3 | ||
|
|
9a1781e6e7 | ||
|
|
3395b51086 | ||
|
|
a3a77a0bb6 | ||
|
|
d35a2b0faa | ||
|
|
88c57d002d | ||
|
|
0d9ff907a8 | ||
|
|
02d8897b82 | ||
|
|
aa8f734bd1 | ||
|
|
0015484679 | ||
|
|
aa5aa798e6 | ||
|
|
63a4a70420 | ||
|
|
2badb04a03 | ||
|
|
a328e12642 | ||
|
|
ce9f1eb201 | ||
|
|
b454ac9ce5 | ||
|
|
a010f7439d | ||
|
|
d7b0ba57e0 | ||
|
|
2ca161f3d8 | ||
|
|
79fdf641c0 | ||
|
|
89194c067b | ||
|
|
b5ab498e75 | ||
|
|
126a5e5e4e | ||
|
|
16d1f9aca8 | ||
|
|
466ab0b404 | ||
|
|
a52a871a94 | ||
|
|
4f9379a5e2 | ||
|
|
4cfad2ef3b | ||
|
|
6e81b0ac32 | ||
|
|
d87023e1ce | ||
|
|
e31d2c9857 | ||
|
|
2174ccf016 | ||
|
|
8f1607e010 | ||
|
|
e93129f1ae | ||
|
|
35b4e190d9 | ||
|
|
b252f52f8e | ||
|
|
eba30c4d79 | ||
|
|
ae1662e433 | ||
|
|
512a3bf09a | ||
|
|
9927ce14d3 | ||
|
|
e5e169f476 | ||
|
|
81df837a33 | ||
|
|
b84b526a43 | ||
|
|
5fc92ab0e6 | ||
|
|
039c2a6d57 | ||
|
|
d053422657 | ||
|
|
90d9eb3520 | ||
|
|
3466da4338 | ||
|
|
17c1a1e465 | ||
|
|
6872ab0e14 | ||
|
|
136d16b7fd | ||
|
|
d651deb31c | ||
|
|
8fb37233f4 | ||
|
|
700194b80d | ||
|
|
ef2dd673ec | ||
|
|
7a3adca771 | ||
|
|
f66b0b7fea | ||
|
|
b6df0209ba | ||
|
|
6b53d263fe | ||
|
|
00024240bb | ||
|
|
237a889f54 | ||
|
|
f5c265b7b5 | ||
|
|
1341ab5703 | ||
|
|
3ded8196c4 | ||
|
|
cbc239ceaa | ||
|
|
a6b43baafe | ||
|
|
ba0a261840 | ||
|
|
3bb0d523d3 | ||
|
|
85cb333a5d | ||
|
|
2abb073b3a | ||
|
|
0634386b2e | ||
|
|
372e9335b1 | ||
|
|
71069ced7f | ||
|
|
fa95191792 | ||
|
|
e945aafd7b | ||
|
|
e9f75adddb | ||
|
|
6ebd8e78c8 | ||
|
|
daa947e7eb | ||
|
|
b117130aac | ||
|
|
4587aba1d4 | ||
|
|
5930a64203 | ||
|
|
97536c4e9b | ||
|
|
e81e33af38 | ||
|
|
a7c56eab86 | ||
|
|
6e580a731e | ||
|
|
a02055e9b5 | ||
|
|
9acd792abe | ||
|
|
4ef55502b4 | ||
|
|
3009863877 | ||
|
|
b67e3ff957 | ||
|
|
d06813f528 | ||
|
|
4544893b4c | ||
|
|
f30fa925ea | ||
|
|
beb91559e5 | ||
|
|
fbc5e74318 |
25
.github/ISSUE_TEMPLATE.md
vendored
25
.github/ISSUE_TEMPLATE.md
vendored
@@ -4,23 +4,30 @@
|
|||||||
remove the "is:open" filter from the search bar to include closed issues.
|
remove the "is:open" filter from the search bar to include closed issues.
|
||||||
|
|
||||||
Check the appropriate type for your issue below by placing an x between the
|
Check the appropriate type for your issue below by placing an x between the
|
||||||
brackets. If none of the below apply, please raise your issue for
|
brackets. For assistance with installation issues, or for any other issues
|
||||||
discussion on our mailing list:
|
other than those listed below, please raise your topic for discussion on
|
||||||
|
our mailing list:
|
||||||
|
|
||||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||||
|
|
||||||
Please note that issues which do not fall under any of the below categories
|
Please note that issues which do not fall under any of the below categories
|
||||||
will be closed.
|
will be closed. Due to an excessive backlog of feature requests, we are
|
||||||
|
not currently accepting any proposals which extend NetBox's feature scope.
|
||||||
|
|
||||||
|
Do not prepend any sort of tag to your issue's title. An administrator will
|
||||||
|
review your issue and assign labels as appropriate.
|
||||||
--->
|
--->
|
||||||
### Issue type
|
### Issue type
|
||||||
[ ] Feature request <!-- Requesting the implementation of a new feature -->
|
[ ] Feature request <!-- An enhancement of existing functionality -->
|
||||||
[ ] Bug report <!-- Reporting unexpected or erroneous behavior -->
|
[ ] Bug report <!-- Unexpected or erroneous behavior -->
|
||||||
[ ] Documentation <!-- Proposing a modification to the documentation -->
|
[ ] Documentation <!-- A modification to the documentation -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Please describe the environment in which you are running NetBox. (Be sure
|
Please describe the environment in which you are running NetBox. (Be sure
|
||||||
to verify that you are running the latest stable release of NetBox before
|
to verify that you are running the latest stable release of NetBox before
|
||||||
submitting a bug report.)
|
submitting a bug report.) If you are submitting a bug report and have made
|
||||||
|
any changes to the code base, please first validate that your bug can be
|
||||||
|
recreated while running an official release.
|
||||||
-->
|
-->
|
||||||
### Environment
|
### Environment
|
||||||
* Python version: <!-- Example: 3.5.4 -->
|
* Python version: <!-- Example: 3.5.4 -->
|
||||||
@@ -28,8 +35,8 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
BUG REPORTS must include:
|
BUG REPORTS must include:
|
||||||
* A list of the steps needed to reproduce the bug
|
* A list of the steps needed for someone else to reproduce the bug
|
||||||
* A description of the expected behavior
|
* A description of the expected and observed behavior
|
||||||
* Any relevant error messages (screenshots may also help)
|
* Any relevant error messages (screenshots may also help)
|
||||||
|
|
||||||
FEATURE REQUESTS must include:
|
FEATURE REQUESTS must include:
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
/netbox/netbox/configuration.py
|
/netbox/netbox/configuration.py
|
||||||
/netbox/netbox/ldap_config.py
|
/netbox/netbox/ldap_config.py
|
||||||
|
/netbox/reports/*
|
||||||
|
!/netbox/reports/__init__.py
|
||||||
/netbox/static
|
/netbox/static
|
||||||
.idea
|
.idea
|
||||||
/*.sh
|
/*.sh
|
||||||
|
|||||||
18
.travis.yml
18
.travis.yml
@@ -1,21 +1,17 @@
|
|||||||
sudo: required
|
sudo: required
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- postgresql
|
||||||
|
addons:
|
||||||
env:
|
postgresql: "9.4"
|
||||||
- DOCKER_TAG=$TRAVIS_TAG
|
|
||||||
|
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "2.7"
|
- "2.7"
|
||||||
- "3.5"
|
- "3.5"
|
||||||
install:
|
install:
|
||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
- pip install pep8
|
- pip install pycodestyle
|
||||||
|
before_script:
|
||||||
|
- psql --version
|
||||||
|
- psql -U postgres -c 'SELECT version();'
|
||||||
script:
|
script:
|
||||||
- ./scripts/cibuild.sh
|
- ./scripts/cibuild.sh
|
||||||
after_success:
|
|
||||||
- if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
|
|
||||||
./scripts/docker-build.sh;
|
|
||||||
fi
|
|
||||||
|
|||||||
138
CONTRIBUTING.md
138
CONTRIBUTING.md
@@ -1,8 +1,8 @@
|
|||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
If you encounter any issues installing or using NetBox, try one of the
|
If you encounter any issues installing or using NetBox, try one of the
|
||||||
following resources to get assistance. Please **do not** open a GitHub
|
following resources to get assistance. Please **do not** open a GitHub issue
|
||||||
issue except to report bugs or request features.
|
except to report bugs or request features.
|
||||||
|
|
||||||
### Mailing List
|
### Mailing List
|
||||||
|
|
||||||
@@ -10,112 +10,108 @@ We have established a Google Groups Mailing List for issues and general
|
|||||||
discussion. This is the best forum for obtaining assistance with NetBox
|
discussion. This is the best forum for obtaining assistance with NetBox
|
||||||
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||||
|
|
||||||
### Freenode IRC
|
### Slack
|
||||||
|
|
||||||
For real-time discussion, you can join the #netbox channel on [Freenode](https://freenode.net/).
|
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
|
||||||
You can connect to Freenode at irc.freenode.net using an IRC client, or
|
|
||||||
you can use their [webchat client](https://webchat.freenode.net/).
|
|
||||||
|
|
||||||
## Reporting Bugs
|
## Reporting Bugs
|
||||||
|
|
||||||
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of
|
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases)
|
||||||
NetBox. If you're running an older version, it's possible that the bug
|
of NetBox. If you're running an older version, it's possible that the bug has
|
||||||
has already been fixed.
|
already been fixed.
|
||||||
|
|
||||||
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has
|
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
|
||||||
already been reported. If you think you may be experiencing a reported
|
to see if the bug you've found has already been reported. If you think you may
|
||||||
issue that hasn't already been resolved, please click "add a reaction"
|
be experiencing a reported issue that hasn't already been resolved, please
|
||||||
in the top right corner of the issue and add a thumbs up (+1). You might
|
click "add a reaction" in the top right corner of the issue and add a thumbs
|
||||||
also want to add a comment describing how it's affecting your
|
up (+1). You mightalso want to add a comment describing how it's affecting your
|
||||||
installation. This will allow us to prioritize bugs based on how many
|
installation. This will allow us to prioritize bugs based on how many users are
|
||||||
users are affected.
|
affected.
|
||||||
|
|
||||||
* If you haven't found an existing issue that describes your suspected
|
* If you haven't found an existing issue that describes your suspected bug,
|
||||||
bug, please inquire about it on the mailing list. **Do not** file an
|
please inquire about it on the mailing list. **Do not** file an issue until you
|
||||||
issue until you have received confirmation that it is in fact a bug.
|
have received confirmation that it is in fact a bug. Invalid issues are very
|
||||||
Invalid issues are very distracting and slow the pace at which NetBox is
|
distracting and slow the pace at which NetBox is developed.
|
||||||
developed.
|
|
||||||
|
|
||||||
* When submitting an issue, please be as descriptive as possible. Be
|
* When submitting an issue, please be as descriptive as possible. Be sure to
|
||||||
sure to include:
|
include:
|
||||||
|
|
||||||
* The environment in which NetBox is running
|
* The environment in which NetBox is running
|
||||||
* The exact steps that can be taken to reproduce the issue (if
|
* The exact steps that can be taken to reproduce the issue (if applicable)
|
||||||
applicable)
|
|
||||||
* Any error messages generated
|
* Any error messages generated
|
||||||
* Screenshots (if applicable)
|
* Screenshots (if applicable)
|
||||||
|
|
||||||
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
|
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
|
||||||
The issue will be reviewed by a moderator after submission and the appropriate
|
The issue will be reviewed by a moderator after submission and the appropriate
|
||||||
labels will be applied.
|
labels will be applied for categorization.
|
||||||
|
|
||||||
* Keep in mind that we prioritize bugs based on their severity and how
|
* Keep in mind that we prioritize bugs based on their severity and how much
|
||||||
much work is required to resolve them. It may take some time for someone
|
work is required to resolve them. It may take some time for someone to address
|
||||||
to address your issue.
|
your issue.
|
||||||
|
|
||||||
## Feature Requests
|
## Feature Requests
|
||||||
|
|
||||||
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're
|
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
|
||||||
requesting is already listed. (Be sure to search closed issues as well,
|
to see if the feature you're requesting is already listed. (Be sure to search
|
||||||
since some feature requests are rejected.) If the feature you'd like to
|
closed issues as well, since some feature requests have been rejected.) If the
|
||||||
see has already been requested, click "add a reaction" in the top right
|
feature you'd like to see has already been requested and is open, click "add a
|
||||||
corner of the issue and add a thumbs up (+1). This ensures that the
|
reaction" in the top right corner of the issue and add a thumbs up (+1). This
|
||||||
issue has a better chance of making it onto the roadmap. Also feel free
|
ensures that the issue has a better chance of receiving attention. Also feel
|
||||||
to add a comment with any additional justification for the feature.
|
free to add a comment with any additional justification for the feature.
|
||||||
(However, note that comments with no substance other than a "+1" will be
|
(However, note that comments with no substance other than a "+1" will be
|
||||||
deleted. Please use GitHub's reactions feature to indicate your
|
deleted. Please use GitHub's reactions feature to indicate your support.)
|
||||||
support.)
|
|
||||||
|
|
||||||
* While suggestions for new features are welcome, it's important to
|
* Due to an excessive backlog of feature requests, we are not currently
|
||||||
limit the scope of NetBox's feature set to avoid feature creep. For
|
accepting any proposals which substantially extend NetBox's functionality
|
||||||
example, the following features would be firmly out of scope for NetBox:
|
beyond its current feature set. This includes the introduction of any new views
|
||||||
|
or models which have not already been proposed in an existing feature request.
|
||||||
* Ticket management
|
|
||||||
* Network state monitoring
|
|
||||||
* Acting as a DNS server
|
|
||||||
* Acting as an authentication server
|
|
||||||
|
|
||||||
* Before filing a new feature request, consider raising your idea on the
|
* Before filing a new feature request, consider raising your idea on the
|
||||||
mailing list first. Feedback you receive there will help validate and
|
mailing list first. Feedback you receive there will help validate and shape the
|
||||||
shape the proposed feature before filing a formal issue.
|
proposed feature before filing a formal issue.
|
||||||
|
|
||||||
* Good feature requests are very narrowly defined. Be sure to enumerate
|
* Good feature requests are very narrowly defined. Be sure to thoroughly
|
||||||
specific functionality and data schema. The more effort you put into
|
describe the functionality and data model(s) being proposed. The more effort
|
||||||
writing a feature request, the better its chance is of being
|
you put into writing a feature request, the better its chance is of being
|
||||||
implemented. Overly broad feature requests will be closed.
|
implemented. Overly broad feature requests will be closed.
|
||||||
|
|
||||||
* When submitting a feature request on GitHub, be sure to include the
|
* When submitting a feature request on GitHub, be sure to include the
|
||||||
following:
|
following:
|
||||||
|
|
||||||
* A detailed description of the proposed functionality
|
* A detailed description of the proposed functionality
|
||||||
* A use case for the feature; who would use it and what value it
|
* A use case for the feature; who would use it and what value it would add
|
||||||
would add to NetBox
|
to NetBox
|
||||||
* A rough description of changes necessary to the database schema
|
* A rough description of changes necessary to the database schema (if
|
||||||
(if applicable)
|
applicable)
|
||||||
* Any third-party libraries or other resources which would be
|
* Any third-party libraries or other resources which would be involved
|
||||||
involved
|
|
||||||
|
|
||||||
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title.
|
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
|
||||||
The issue will be reviewed by a moderator after submission and the appropriate
|
title. The issue will be reviewed by a moderator after submission and the
|
||||||
labels will be applied.
|
appropriate labels will be applied for categorization.
|
||||||
|
|
||||||
## Submitting Pull Requests
|
## Submitting Pull Requests
|
||||||
|
|
||||||
* Be sure to open an issue before starting work on a pull request, and
|
* Be sure to open an issue before starting work on a pull request, and discuss
|
||||||
discuss your idea with the NetBox maintainers before beginning work.
|
your idea with the NetBox maintainers before beginning work. This will help
|
||||||
This will help prevent wasting time on something that might we might not
|
prevent wasting time on something that might we might not be able to implement.
|
||||||
be able to implement. When suggesting a new feature, also make sure it
|
When suggesting a new feature, also make sure it won't conflict with any work
|
||||||
won't conflict with any work that's already in progress.
|
that's already in progress.
|
||||||
|
|
||||||
* When submitting a pull request, please be sure to work off of the
|
* When submitting a pull request, please be sure to work off of the `develop`
|
||||||
`develop` branch, rather than `master`. In NetBox, the `develop` branch
|
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||||
is used for ongoing development, while `master` is used for tagging new
|
development, while `master` is used for tagging new stable releases.
|
||||||
stable releases.
|
|
||||||
|
|
||||||
* All code submissions should meet the following criteria (CI will
|
* All code submissions should meet the following criteria (CI will enforce
|
||||||
enforce these checks):
|
these checks):
|
||||||
|
|
||||||
* Python syntax is valid
|
* Python syntax is valid
|
||||||
* All tests pass when run with `./manage.py test`
|
* All tests pass when run with `./manage.py test`
|
||||||
* PEP 8 compliance is enforced, with the exception that lines may be
|
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||||
greater than 80 characters in length
|
greater than 80 characters in length
|
||||||
|
|
||||||
|
## Commenting
|
||||||
|
|
||||||
|
Only comment on an issue if you are sharing a relevant idea or constructive
|
||||||
|
feedback. **Do not** comment on an issue just to show your support (give the
|
||||||
|
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
|
||||||
|
reduce noise in the discussion.
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -1,12 +1,18 @@
|
|||||||

|

|
||||||
|
|
||||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
|
NetBox is an IP address management (IPAM) and data center infrastructure
|
||||||
|
management (DCIM) tool. Initially conceived by the network engineering team at
|
||||||
|
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
|
||||||
|
to address the needs of network and infrastructure engineers.
|
||||||
|
|
||||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
|
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||||
|
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
|
||||||
|
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
|
||||||
|
|
||||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||||
|
|
||||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
|
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||||
|
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||||
|
|
||||||
### Build Status
|
### Build Status
|
||||||
|
|
||||||
@@ -27,10 +33,12 @@ NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended.
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
|
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||||
|
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases)
|
||||||
|
and run `upgrade.sh`.
|
||||||
|
|
||||||
## Alternative Installations
|
## Alternative Installations
|
||||||
|
|
||||||
* [Docker container](https://github.com/digitalocean/netbox-docker)
|
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||||
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
|
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
|
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Supported HTTP methods:
|
|||||||
|
|
||||||
* `GET`: Retrieve an object or list of objects
|
* `GET`: Retrieve an object or list of objects
|
||||||
* `POST`: Create a new object
|
* `POST`: Create a new object
|
||||||
* `PUT`: Update an existing object
|
* `PUT`: Update an existing object, all mandatory fields must be specified
|
||||||
|
* `PATCH`: Updates an existing object, only specifying the field to be changed
|
||||||
* `DELETE`: Delete an existing object
|
* `DELETE`: Delete an existing object
|
||||||
|
|
||||||
To authenticate a request, attach your token in an `Authorization` header:
|
To authenticate a request, attach your token in an `Authorization` header:
|
||||||
@@ -81,15 +82,15 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6
|
|||||||
|
|
||||||
### Creating a new site
|
### Creating a new site
|
||||||
|
|
||||||
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required.
|
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. This example includes one non required field, "region."
|
||||||
|
|
||||||
```
|
```
|
||||||
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}'
|
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site", "region": 5}'
|
||||||
{
|
{
|
||||||
"id": 16,
|
"id": 16,
|
||||||
"name": "My New Site",
|
"name": "My New Site",
|
||||||
"slug": "my-new-site",
|
"slug": "my-new-site",
|
||||||
"region": null,
|
"region": 5,
|
||||||
"tenant": null,
|
"tenant": null,
|
||||||
"facility": "",
|
"facility": "",
|
||||||
"asn": null,
|
"asn": null,
|
||||||
@@ -101,21 +102,29 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
|
|||||||
"comments": ""
|
"comments": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
Note that in this example we are creating a site bound to a region with the ID of 5. For write API actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action.
|
||||||
|
|
||||||
### Modify an existing site
|
### Modify an existing site
|
||||||
|
|
||||||
Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included.
|
Make an authenticated `PUT` request to the site detail endpoint. As with a create (`POST`) request, all mandatory fields must be included.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
|
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Modify an object by changing a field
|
||||||
|
|
||||||
|
Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
|
||||||
|
```
|
||||||
|
$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
|
||||||
|
```
|
||||||
|
|
||||||
### Delete an existing site
|
### Delete an existing site
|
||||||
|
|
||||||
Send an authenticated `DELETE` request to the site detail endpoint.
|
Send an authenticated `DELETE` request to the site detail endpoint.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
|
$ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
|
||||||
* Connected to localhost (127.0.0.1) port 8000 (#0)
|
* Connected to localhost (127.0.0.1) port 8000 (#0)
|
||||||
> DELETE /api/dcim/sites/16/ HTTP/1.1
|
> DELETE /api/dcim/sites/16/ HTTP/1.1
|
||||||
> User-Agent: curl/7.35.0
|
> User-Agent: curl/7.35.0
|
||||||
@@ -135,4 +144,4 @@ $ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9
|
|||||||
* Closing connection 0
|
* Closing connection 0
|
||||||
```
|
```
|
||||||
|
|
||||||
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ REST stands for [representational state transfer](https://en.wikipedia.org/wiki/
|
|||||||
|
|
||||||
* `GET`: Retrieve an object or list of objects
|
* `GET`: Retrieve an object or list of objects
|
||||||
* `POST`: Create an object
|
* `POST`: Create an object
|
||||||
* `PUT` / `PATCH`: Modify an existing object
|
* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
|
||||||
* `DELETE`: Delete an existing object
|
* `DELETE`: Delete an existing object
|
||||||
|
|
||||||
The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
|
The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
|
||||||
@@ -88,10 +88,10 @@ The base serializer is used to represent the default view of a model. This inclu
|
|||||||
"vid": 101,
|
"vid": 101,
|
||||||
"name": "Users-Floor1",
|
"name": "Users-Floor1",
|
||||||
"tenant": null,
|
"tenant": null,
|
||||||
"status": [
|
"status": {
|
||||||
1,
|
"value": 1,
|
||||||
"Active"
|
"label": "Active"
|
||||||
],
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"url": "http://localhost:8000/api/ipam/roles/9/",
|
"url": "http://localhost:8000/api/ipam/roles/9/",
|
||||||
@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name.
|
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
|
||||||
|
|
||||||
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
||||||
|
|
||||||
@@ -122,6 +122,37 @@ When a base serializer includes one or more nested serializers, the hierarchical
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Static Choice Fields
|
||||||
|
|
||||||
|
Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.
|
||||||
|
|
||||||
|
Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"label": "Container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"label": "Active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 2,
|
||||||
|
"label": "Reserved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 3,
|
||||||
|
"label": "Deprecated"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`.
|
||||||
|
|
||||||
|
A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app.
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
|
|
||||||
API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:
|
API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ ADMINS = [
|
|||||||
|
|
||||||
## BANNER_BOTTOM
|
## BANNER_BOTTOM
|
||||||
|
|
||||||
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set:
|
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
|
||||||
|
|
||||||
```
|
```
|
||||||
BANNER_TOP = 'Your banner text'
|
BANNER_TOP = 'Your banner text'
|
||||||
@@ -26,6 +26,12 @@ BANNER_BOTTOM = BANNER_TOP
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## BANNER_LOGIN
|
||||||
|
|
||||||
|
The value of this variable will be displayed on the login page above the login form. HTML is allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## BASE_PATH
|
## BASE_PATH
|
||||||
|
|
||||||
Default: None
|
Default: None
|
||||||
@@ -139,7 +145,7 @@ An API consumer can request an arbitrary number of objects by appending the "lim
|
|||||||
|
|
||||||
Default: $BASE_DIR/netbox/media/
|
Default: $BASE_DIR/netbox/media/
|
||||||
|
|
||||||
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media` directory within the base NetBox installation path.
|
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,14 +191,6 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## NETBOX_USERNAME (Deprecated)
|
|
||||||
|
|
||||||
## NETBOX_PASSWORD (Deprecated)
|
|
||||||
|
|
||||||
These settings have been deprecated and will be removed in NetBox v2.2. Please use `NAPALM_USERNAME` and `NAPALM_PASSWORD` instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PAGINATE_COUNT
|
## PAGINATE_COUNT
|
||||||
|
|
||||||
Default: 50
|
Default: 50
|
||||||
@@ -209,6 +207,14 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## REPORTS_ROOT
|
||||||
|
|
||||||
|
Default: $BASE_DIR/netbox/reports/
|
||||||
|
|
||||||
|
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TIME_ZONE
|
## TIME_ZONE
|
||||||
|
|
||||||
Default: UTC
|
Default: UTC
|
||||||
|
|||||||
@@ -112,3 +112,11 @@ Console ports connect only to console server ports, and power ports connect only
|
|||||||
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
|
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
|
||||||
|
|
||||||
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Virtual Chassis
|
||||||
|
|
||||||
|
A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
|
||||||
|
|
||||||
|
It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.
|
||||||
|
|||||||
@@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each
|
|||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
|
||||||
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
|
A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
|
||||||
|
|||||||
29
docs/data-model/virtualization.md
Normal file
29
docs/data-model/virtualization.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it.
|
||||||
|
|
||||||
|
# Clusters
|
||||||
|
|
||||||
|
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group.
|
||||||
|
|
||||||
|
Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
|
||||||
|
|
||||||
|
### Cluster Types
|
||||||
|
|
||||||
|
A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider.
|
||||||
|
|
||||||
|
### Cluster Groups
|
||||||
|
|
||||||
|
Cluster groups may be created for the purpose of organizing clusters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Virtual Machines
|
||||||
|
|
||||||
|
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
|
||||||
|
|
||||||
|
Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays.
|
||||||
|
|
||||||
|
The following resources can be defined for each VM:
|
||||||
|
|
||||||
|
* vCPU count
|
||||||
|
* Memory (MB)
|
||||||
|
* Disk space (GB)
|
||||||
53
docs/development/utility-views.md
Normal file
53
docs/development/utility-views.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Utility Views
|
||||||
|
|
||||||
|
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
|
||||||
|
|
||||||
|
## Individual Views
|
||||||
|
|
||||||
|
### ObjectListView
|
||||||
|
|
||||||
|
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
|
||||||
|
|
||||||
|
### ObjectEditView
|
||||||
|
|
||||||
|
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
|
||||||
|
|
||||||
|
### ObjectDeleteView
|
||||||
|
|
||||||
|
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
|
||||||
|
|
||||||
|
## Bulk Views
|
||||||
|
|
||||||
|
### BulkCreateView
|
||||||
|
|
||||||
|
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
|
||||||
|
|
||||||
|
### BulkImportView
|
||||||
|
|
||||||
|
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
|
||||||
|
|
||||||
|
### BulkEditView
|
||||||
|
|
||||||
|
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
|
||||||
|
|
||||||
|
### BulkDeleteView
|
||||||
|
|
||||||
|
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
|
||||||
|
|
||||||
|
## Component Views
|
||||||
|
|
||||||
|
### ComponentCreateView
|
||||||
|
|
||||||
|
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
|
||||||
|
|
||||||
|
### ComponentEditView
|
||||||
|
|
||||||
|
A subclass of `ObjectEditView`: Updates an individual component object.
|
||||||
|
|
||||||
|
### ComponentDeleteView
|
||||||
|
|
||||||
|
A subclass of `ObjectDeleteView`: Deletes an individual component object.
|
||||||
|
|
||||||
|
### BulkComponentCreateView
|
||||||
|
|
||||||
|
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.
|
||||||
@@ -6,6 +6,7 @@ NetBox is an open source web application designed to help manage and document co
|
|||||||
* **Equipment racks** - Organized by group and site
|
* **Equipment racks** - Organized by group and site
|
||||||
* **Devices** - Types of devices and where they are installed
|
* **Devices** - Types of devices and where they are installed
|
||||||
* **Connections** - Network, console, and power connections among devices
|
* **Connections** - Network, console, and power connections among devices
|
||||||
|
* **Virtualization** - Virtual machines and clusters
|
||||||
* **Data circuits** - Long-haul communications circuits and providers
|
* **Data circuits** - Long-haul communications circuits and providers
|
||||||
* **Secrets** - Encrypted storage of sensitive credentials
|
* **Secrets** - Encrypted storage of sensitive credentials
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
|||||||
| HTTP Service | nginx or Apache |
|
| HTTP Service | nginx or Apache |
|
||||||
| WSGI Service | gunicorn or uWSGI |
|
| WSGI Service | gunicorn or uWSGI |
|
||||||
| Application | Django/Python |
|
| Application | Django/Python |
|
||||||
| Database | PostgreSQL |
|
| Database | PostgreSQL 9.4+ |
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ sudo pip install django-auth-ldap
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`.
|
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||||
|
|
||||||
## General Server Configuration
|
## General Server Configuration
|
||||||
|
|
||||||
@@ -52,10 +52,12 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
|
|||||||
LDAP_IGNORE_CERT_ERRORS = True
|
LDAP_IGNORE_CERT_ERRORS = True
|
||||||
```
|
```
|
||||||
|
|
||||||
|
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
||||||
|
|
||||||
## User Authentication
|
## User Authentication
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from django_auth_ldap.config import LDAPSearch
|
from django_auth_ldap.config import LDAPSearch
|
||||||
@@ -78,12 +80,14 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
# User Groups for Permissions
|
# User Groups for Permissions
|
||||||
|
!!! info
|
||||||
|
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
|
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
|
||||||
|
|
||||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||||
# heirarchy.
|
# hierarchy.
|
||||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
|
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
|
||||||
"(objectClass=group)")
|
"(objectClass=group)")
|
||||||
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
|
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
|
This section of the documentation discusses installing and configuring the NetBox application.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Python 3 is strongly encouraged for new installations. Support for Python 2 will be discontinued in the near future. This documentation includes a guide on [migrating from Python 2 to Python 3](migrating-to-python3).
|
||||||
|
|
||||||
**Ubuntu**
|
**Ubuntu**
|
||||||
|
|
||||||
Python 3:
|
Python 3:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
# apt-get install -y python3 python3-dev python3-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||||
|
# easy_install3 pip
|
||||||
```
|
```
|
||||||
|
|
||||||
Python 2:
|
Python 2:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
# apt-get install -y python2.7 python-dev python-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||||
|
# easy_install pip
|
||||||
```
|
```
|
||||||
|
|
||||||
**CentOS**
|
**CentOS**
|
||||||
@@ -20,16 +27,16 @@ Python 3:
|
|||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# yum install -y epel-release
|
# yum install -y epel-release
|
||||||
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
|
||||||
# easy_install-3.4 pip
|
# easy_install-3.4 pip
|
||||||
# ln -s -f python3.4 /usr/bin/python
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Python 2:
|
Python 2:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# yum install -y epel-release
|
# yum install -y epel-release
|
||||||
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
|
||||||
|
# easy_install pip
|
||||||
```
|
```
|
||||||
|
|
||||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||||
@@ -81,6 +88,11 @@ Resolving deltas: 100% (1495/1495), done.
|
|||||||
Checking connectivity... done.
|
Checking connectivity... done.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
|
||||||
|
|
||||||
|
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
|
||||||
|
|
||||||
## Install Python Packages
|
## Install Python Packages
|
||||||
|
|
||||||
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
||||||
@@ -97,6 +109,9 @@ Python 2:
|
|||||||
# pip install -r requirements.txt
|
# pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`.
|
||||||
|
|
||||||
### NAPALM Automation
|
### NAPALM Automation
|
||||||
|
|
||||||
As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
|
As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
|
||||||
@@ -158,13 +173,13 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
|
|||||||
# Run Database Migrations
|
# Run Database Migrations
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3.
|
The examples on the rest of this page call the `python3` executable. Replace this with `python2` or `python` if you're using Python 2.
|
||||||
|
|
||||||
Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# cd /opt/netbox/netbox/
|
# cd /opt/netbox/netbox/
|
||||||
# python manage.py migrate
|
# python3 manage.py migrate
|
||||||
Operations to perform:
|
Operations to perform:
|
||||||
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
|
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
|
||||||
Running migrations:
|
Running migrations:
|
||||||
@@ -182,7 +197,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
|
|||||||
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# python manage.py createsuperuser
|
# python3 manage.py createsuperuser
|
||||||
Username: admin
|
Username: admin
|
||||||
Email address: admin@example.com
|
Email address: admin@example.com
|
||||||
Password:
|
Password:
|
||||||
@@ -193,7 +208,7 @@ Superuser created successfully.
|
|||||||
# Collect Static Files
|
# Collect Static Files
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# python manage.py collectstatic --no-input
|
# python3 manage.py collectstatic --no-input
|
||||||
|
|
||||||
You have requested to collect static files at the destination
|
You have requested to collect static files at the destination
|
||||||
location as specified in your settings:
|
location as specified in your settings:
|
||||||
@@ -214,7 +229,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
|
|||||||
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
|
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# python manage.py loaddata initial_data
|
# python3 manage.py loaddata initial_data
|
||||||
Installed 43 object(s) from 4 fixture(s)
|
Installed 43 object(s) from 4 fixture(s)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -223,7 +238,7 @@ Installed 43 object(s) from 4 fixture(s)
|
|||||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# python manage.py runserver 0.0.0.0:8000 --insecure
|
# python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||||
Performing system checks...
|
Performing system checks...
|
||||||
|
|
||||||
System check identified no issues (0 silenced).
|
System check identified no issues (0 silenced).
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
|
NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).)
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. 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.
|
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 7.4. 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.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
NetBox v2.2 and later requires PostgreSQL 9.4 or higher.
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
**Ubuntu**
|
**Ubuntu**
|
||||||
|
|
||||||
|
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# apt-get update
|
# apt-get update
|
||||||
# apt-get install -y postgresql libpq-dev
|
# apt-get install -y postgresql libpq-dev
|
||||||
@@ -14,22 +19,26 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
|
|||||||
|
|
||||||
**CentOS**
|
**CentOS**
|
||||||
|
|
||||||
|
CentOS 7.4 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# yum install -y postgresql postgresql-server postgresql-devel
|
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
|
||||||
# postgresql-setup initdb
|
# yum install postgresql96 postgresql96-server postgresql96-devel
|
||||||
|
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
|
||||||
```
|
```
|
||||||
|
|
||||||
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
|
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/9.6/data/pg_hba.conf`. For example:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
host all all 127.0.0.1/32 md5
|
host all all 127.0.0.1/32 md5
|
||||||
host all all ::1/128 md5
|
host all all ::1/128 md5
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, start the service:
|
Then, start the service and enable it to run at boot:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# systemctl start postgresql
|
# systemctl start postgresql-9.6
|
||||||
|
# systemctl enable postgresql-9.6
|
||||||
```
|
```
|
||||||
|
|
||||||
# Database Creation
|
# Database Creation
|
||||||
@@ -41,7 +50,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
|
|||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# sudo -u postgres psql
|
# sudo -u postgres psql
|
||||||
psql (9.3.13)
|
psql (9.4.5)
|
||||||
Type "help" for help.
|
Type "help" for help.
|
||||||
|
|
||||||
postgres=# CREATE DATABASE netbox;
|
postgres=# CREATE DATABASE netbox;
|
||||||
@@ -53,10 +62,10 @@ GRANT
|
|||||||
postgres=# \q
|
postgres=# \q
|
||||||
```
|
```
|
||||||
|
|
||||||
You can verify that authentication works issuing the following command and providing the configured password:
|
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# psql -U netbox -h localhost -W
|
# psql -U netbox -W -h localhost netbox
|
||||||
```
|
```
|
||||||
|
|
||||||
If successful, you will enter a `postgres` prompt. Type `\q` to exit.
|
If successful, you will enter a `netbox` prompt. Type `\q` to exit.
|
||||||
|
|||||||
@@ -12,25 +12,37 @@ Download and extract the latest version:
|
|||||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||||
# cd /opt/
|
# cd /opt/
|
||||||
# ln -sf netbox-X.Y.Z/ netbox
|
# ln -sfn netbox-X.Y.Z/ netbox
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the 'configuration.py' you created when first installing to the new version:
|
Copy the 'configuration.py' you created when first installing to the new version:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
|
||||||
|
```
|
||||||
|
|
||||||
|
Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
|
||||||
```
|
```
|
||||||
|
|
||||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
|
# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the LDAP configuration if using LDAP:
|
Copy the LDAP configuration if using LDAP:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
|
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option B: Clone the Git Repository (latest master release)
|
## Option B: Clone the Git Repository (latest master release)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
|||||||
ProxyPass !
|
ProxyPass !
|
||||||
</Location>
|
</Location>
|
||||||
|
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
ProxyPass / http://127.0.0.1:8001/
|
ProxyPass / http://127.0.0.1:8001/
|
||||||
ProxyPassReverse / http://127.0.0.1:8001/
|
ProxyPassReverse / http://127.0.0.1:8001/
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
@@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
|||||||
```no-highlight
|
```no-highlight
|
||||||
# a2enmod proxy
|
# a2enmod proxy
|
||||||
# a2enmod proxy_http
|
# a2enmod proxy_http
|
||||||
|
# a2enmod headers
|
||||||
# a2ensite netbox
|
# a2ensite netbox
|
||||||
# service apache2 restart
|
# service apache2 restart
|
||||||
```
|
```
|
||||||
|
|||||||
131
docs/miscellaneous/reports.md
Normal file
131
docs/miscellaneous/reports.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# NetBox Reports
|
||||||
|
|
||||||
|
A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that:
|
||||||
|
|
||||||
|
* All top-of-rack switches have a console connection
|
||||||
|
* Every router has a loopback interface with an IP address assigned
|
||||||
|
* Each interface description conforms to a standard format
|
||||||
|
* Every site has a minimum set of VLANs defined
|
||||||
|
* All IP addresses have a parent prefix
|
||||||
|
|
||||||
|
...and so on. Reports are completely customizable, so there's practically no limit to what you can test for.
|
||||||
|
|
||||||
|
## Writing Reports
|
||||||
|
|
||||||
|
Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
|
||||||
|
|
||||||
|
For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`.
|
||||||
|
|
||||||
|
```
|
||||||
|
from extras.reports import Report
|
||||||
|
|
||||||
|
class DeviceConnectionsReport(Report):
|
||||||
|
description = "Validate the minimum physical connections for each device"
|
||||||
|
|
||||||
|
class DeviceIPsReport(Report):
|
||||||
|
description = "Check that every device has a primary IP address assigned"
|
||||||
|
```
|
||||||
|
|
||||||
|
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
||||||
|
|
||||||
|
```
|
||||||
|
from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE
|
||||||
|
from dcim.models import ConsolePort, Device, PowerPort
|
||||||
|
from extras.reports import Report
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConnectionsReport(Report):
|
||||||
|
description = "Validate the minimum physical connections for each device"
|
||||||
|
|
||||||
|
def test_console_connection(self):
|
||||||
|
|
||||||
|
# Check that every console port for every active device has a connection defined.
|
||||||
|
for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE):
|
||||||
|
if console_port.cs_port is None:
|
||||||
|
self.log_failure(
|
||||||
|
console_port.device,
|
||||||
|
"No console connection defined for {}".format(console_port.name)
|
||||||
|
)
|
||||||
|
elif console_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||||
|
self.log_warning(
|
||||||
|
console_port.device,
|
||||||
|
"Console connection for {} marked as planned".format(console_port.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log_success(console_port.device)
|
||||||
|
|
||||||
|
def test_power_connections(self):
|
||||||
|
|
||||||
|
# Check that every active device has at least two connected power supplies.
|
||||||
|
for device in Device.objects.filter(status=STATUS_ACTIVE):
|
||||||
|
connected_ports = 0
|
||||||
|
for power_port in PowerPort.objects.filter(device=device):
|
||||||
|
if power_port.power_outlet is not None:
|
||||||
|
connected_ports += 1
|
||||||
|
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||||
|
self.log_warning(
|
||||||
|
device,
|
||||||
|
"Power connection for {} marked as planned".format(power_port.name)
|
||||||
|
)
|
||||||
|
if connected_ports < 2:
|
||||||
|
self.log_failure(
|
||||||
|
device,
|
||||||
|
"{} connected power supplies found (2 needed)".format(connected_ports)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log_success(device)
|
||||||
|
```
|
||||||
|
|
||||||
|
As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
|
||||||
|
|
||||||
|
The following methods are available to log results within a report:
|
||||||
|
|
||||||
|
* log(message)
|
||||||
|
* log_success(object, message=None)
|
||||||
|
* log_info(object, message)
|
||||||
|
* log_warning(object, message)
|
||||||
|
* log_failure(object, message)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`.
|
||||||
|
|
||||||
|
Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
|
||||||
|
|
||||||
|
## Running Reports
|
||||||
|
|
||||||
|
### Via the Web UI
|
||||||
|
|
||||||
|
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
|
||||||
|
|
||||||
|
Once a report has been run, its associated results will be included in the report view.
|
||||||
|
|
||||||
|
### Via the API
|
||||||
|
|
||||||
|
To run a report via the API, simply issue a POST request to its `run` endpoint. Reports are identified by their module and class name.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/extras/reports/<module>.<name>/run/
|
||||||
|
```
|
||||||
|
|
||||||
|
Our example report above would be called as:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via the CLI
|
||||||
|
|
||||||
|
Reports can be run on the CLI by invoking the management command:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 manage.py runreport <module>
|
||||||
|
```
|
||||||
|
|
||||||
|
One or more report modules may be specified.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
||||||
|
|
||||||
```
|
```
|
||||||
./manage.py nbshell
|
./manage.py nbshell
|
||||||
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
|
|||||||
982
|
982
|
||||||
```
|
```
|
||||||
|
|
||||||
Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
|
Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
|
||||||
|
|
||||||
```
|
```
|
||||||
>>> Device.objects.filter(tenant__name='Pied Piper')
|
>>> Device.objects.filter(tenant__name='Pied Piper')
|
||||||
@@ -18,14 +18,18 @@ pages:
|
|||||||
- 'IPAM': 'data-model/ipam.md'
|
- 'IPAM': 'data-model/ipam.md'
|
||||||
- 'Secrets': 'data-model/secrets.md'
|
- 'Secrets': 'data-model/secrets.md'
|
||||||
- 'Tenancy': 'data-model/tenancy.md'
|
- 'Tenancy': 'data-model/tenancy.md'
|
||||||
|
- 'Virtualization': 'data-model/virtualization.md'
|
||||||
- 'Extras': 'data-model/extras.md'
|
- 'Extras': 'data-model/extras.md'
|
||||||
- 'API':
|
- 'API':
|
||||||
- 'Overview': 'api/overview.md'
|
- 'Overview': 'api/overview.md'
|
||||||
- 'Authentication': 'api/authentication.md'
|
- 'Authentication': 'api/authentication.md'
|
||||||
- 'Working with Secrets': 'api/working-with-secrets.md'
|
- 'Working with Secrets': 'api/working-with-secrets.md'
|
||||||
- 'Examples': 'api/examples.md'
|
- 'Examples': 'api/examples.md'
|
||||||
- 'Shell':
|
- 'Miscellaneous':
|
||||||
- 'Introduction': 'shell/intro.md'
|
- 'Reports': 'miscellaneous/reports.md'
|
||||||
|
- 'Shell': 'miscellaneous/shell.md'
|
||||||
|
- 'Development':
|
||||||
|
- 'Utility Views': 'development/utility-views.md'
|
||||||
|
|
||||||
markdown_extensions:
|
markdown_extensions:
|
||||||
- admonition:
|
- admonition:
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -19,7 +20,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
|
|||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
|
|||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,14 +67,15 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class CircuitSerializer(CustomFieldModelSerializer):
|
class CircuitSerializer(CustomFieldModelSerializer):
|
||||||
provider = NestedProviderSerializer()
|
provider = NestedProviderSerializer()
|
||||||
|
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
|
||||||
type = NestedCircuitTypeSerializer()
|
type = NestedCircuitTypeSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||||
'custom_fields',
|
'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -90,8 +92,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||||
'custom_fields',
|
'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class CircuitsRootView(routers.APIRootView):
|
|||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.APIRootView = CircuitsRootView
|
router.APIRootView = CircuitsRootView
|
||||||
|
|
||||||
|
# Field choices
|
||||||
|
router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice')
|
||||||
|
|
||||||
# Providers
|
# Providers
|
||||||
router.register(r'providers', views.ProviderViewSet)
|
router.register(r'providers', views.ProviderViewSet)
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
|
|
||||||
from circuits import filters
|
from circuits import filters
|
||||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
|
||||||
from extras.api.serializers import RenderedGraphSerializer
|
from extras.api.serializers import RenderedGraphSerializer
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from utilities.api import WritableSerializerMixin
|
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||||
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Field choices
|
||||||
|
#
|
||||||
|
|
||||||
|
class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||||
|
fields = (
|
||||||
|
(Circuit, ['status']),
|
||||||
|
(CircuitTermination, ['term_side']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Providers
|
# Providers
|
||||||
#
|
#
|
||||||
|
|
||||||
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class ProviderViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Provider.objects.all()
|
queryset = Provider.objects.all()
|
||||||
serializer_class = serializers.ProviderSerializer
|
serializer_class = serializers.ProviderSerializer
|
||||||
write_serializer_class = serializers.WritableProviderSerializer
|
write_serializer_class = serializers.WritableProviderSerializer
|
||||||
@@ -50,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
|||||||
# Circuits
|
# Circuits
|
||||||
#
|
#
|
||||||
|
|
||||||
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class CircuitViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||||
serializer_class = serializers.CircuitSerializer
|
serializer_class = serializers.CircuitSerializer
|
||||||
write_serializer_class = serializers.WritableCircuitSerializer
|
write_serializer_class = serializers.WritableCircuitSerializer
|
||||||
@@ -61,7 +70,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Circuit Terminations
|
# Circuit Terminations
|
||||||
#
|
#
|
||||||
|
|
||||||
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
|
class CircuitTerminationViewSet(ModelViewSet):
|
||||||
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
||||||
serializer_class = serializers.CircuitTerminationSerializer
|
serializer_class = serializers.CircuitTerminationSerializer
|
||||||
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
|
||||||
|
# Circuit statuses
|
||||||
|
CIRCUIT_STATUS_DEPROVISIONING = 0
|
||||||
|
CIRCUIT_STATUS_ACTIVE = 1
|
||||||
|
CIRCUIT_STATUS_PLANNED = 2
|
||||||
|
CIRCUIT_STATUS_PROVISIONING = 3
|
||||||
|
CIRCUIT_STATUS_OFFLINE = 4
|
||||||
|
CIRCUIT_STATUS_DECOMMISSIONED = 5
|
||||||
|
CIRCUIT_STATUS_CHOICES = [
|
||||||
|
[CIRCUIT_STATUS_PLANNED, 'Planned'],
|
||||||
|
[CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
|
||||||
|
[CIRCUIT_STATUS_ACTIVE, 'Active'],
|
||||||
|
[CIRCUIT_STATUS_OFFLINE, 'Offline'],
|
||||||
|
[CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
|
||||||
|
[CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
|
||||||
|
]
|
||||||
|
|
||||||
# CircuitTermination sides
|
# CircuitTermination sides
|
||||||
TERM_SIDE_A = 'A'
|
TERM_SIDE_A = 'A'
|
||||||
TERM_SIDE_Z = 'Z'
|
TERM_SIDE_Z = 'Z'
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.filters import CustomFieldFilterSet
|
from extras.filters import CustomFieldFilterSet
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
from utilities.filters import NumericInFilter
|
||||||
|
from .constants import CIRCUIT_STATUS_CHOICES
|
||||||
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
||||||
|
|
||||||
|
|
||||||
@@ -78,12 +78,16 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Circuit type (slug)',
|
label='Circuit type (slug)',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=CIRCUIT_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
|
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
|
||||||
SmallTextarea, SlugField,
|
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||||
)
|
)
|
||||||
|
from .constants import CIRCUIT_STATUS_CHOICES
|
||||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class ProviderCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Provider
|
model = Provider
|
||||||
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
|
fields = Provider.csv_headers
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': 'Provider name',
|
'name': 'Provider name',
|
||||||
'asn': '32-bit autonomous system number',
|
'asn': '32-bit autonomous system number',
|
||||||
@@ -85,6 +85,17 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = ['name', 'slug']
|
fields = ['name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTypeCSVForm(forms.ModelForm):
|
||||||
|
slug = SlugField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CircuitType
|
||||||
|
fields = CircuitType.csv_headers
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Name of circuit type',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Circuits
|
# Circuits
|
||||||
#
|
#
|
||||||
@@ -95,7 +106,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||||
'comments',
|
'comments',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
@@ -122,6 +133,11 @@ class CircuitCSVForm(forms.ModelForm):
|
|||||||
'invalid_choice': 'Invalid circuit type.'
|
'invalid_choice': 'Invalid circuit type.'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
status = CSVChoiceField(
|
||||||
|
choices=CIRCUIT_STATUS_CHOICES,
|
||||||
|
required=False,
|
||||||
|
help_text='Operational status'
|
||||||
|
)
|
||||||
tenant = forms.ModelChoiceField(
|
tenant = forms.ModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -134,13 +150,16 @@ class CircuitCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
fields = [
|
||||||
|
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||||
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
||||||
|
status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='')
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||||
description = forms.CharField(max_length=100, required=False)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
@@ -161,10 +180,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug'
|
to_field_name='slug'
|
||||||
)
|
)
|
||||||
|
status = AnnotatedMultipleChoiceField(
|
||||||
|
choices=CIRCUIT_STATUS_CHOICES,
|
||||||
|
annotate=Circuit.objects.all(),
|
||||||
|
annotate_field='status',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
site = FilterChoiceField(
|
site = FilterChoiceField(
|
||||||
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
|
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
|
||||||
|
|||||||
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-06 18:48
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0009_unicode_literals'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuit',
|
||||||
|
name='status',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,12 +5,12 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
|
|
||||||
|
from dcim.constants import STATUS_CLASSES
|
||||||
from dcim.fields import ASNField
|
from dcim.fields import ASNField
|
||||||
from extras.models import CustomFieldModel, CustomFieldValue
|
from extras.models import CustomFieldModel, CustomFieldValue
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.utils import csv_format
|
|
||||||
from utilities.models import CreatedUpdatedModel
|
from utilities.models import CreatedUpdatedModel
|
||||||
from .constants import *
|
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@@ -29,7 +29,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
|
|
||||||
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url']
|
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
@@ -41,13 +41,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return reverse('circuits:provider', args=[self.slug])
|
return reverse('circuits:provider', args=[self.slug])
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.name,
|
self.name,
|
||||||
self.slug,
|
self.slug,
|
||||||
self.asn,
|
self.asn,
|
||||||
self.account,
|
self.account,
|
||||||
self.portal_url,
|
self.portal_url,
|
||||||
])
|
self.noc_contact,
|
||||||
|
self.admin_contact,
|
||||||
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@@ -59,6 +62,8 @@ class CircuitType(models.Model):
|
|||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
@@ -68,6 +73,12 @@ class CircuitType(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
|
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||||
@@ -79,6 +90,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
||||||
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
||||||
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
|
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
|
||||||
|
status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
|
||||||
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
||||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||||
@@ -86,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
|
|
||||||
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
|
csv_headers = [
|
||||||
|
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['provider', 'cid']
|
ordering = ['provider', 'cid']
|
||||||
@@ -99,15 +113,20 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return reverse('circuits:circuit', args=[self.pk])
|
return reverse('circuits:circuit', args=[self.pk])
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.cid,
|
self.cid,
|
||||||
self.provider.name,
|
self.provider.name,
|
||||||
self.type.name,
|
self.type.name,
|
||||||
|
self.get_status_display(),
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.install_date.isoformat() if self.install_date else None,
|
self.install_date,
|
||||||
self.commit_rate,
|
self.commit_rate,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_status_class(self):
|
||||||
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
def _get_termination(self, side):
|
def _get_termination(self, side):
|
||||||
for ct in self.terminations.all():
|
for ct in self.terminations.all():
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
|
|
||||||
|
from tenancy.tables import COL_TENANT
|
||||||
from utilities.tables import BaseTable, ToggleColumn
|
from utilities.tables import BaseTable, ToggleColumn
|
||||||
from .models import Circuit, CircuitType, Provider
|
from .models import Circuit, CircuitType, Provider
|
||||||
|
|
||||||
|
|
||||||
CIRCUITTYPE_ACTIONS = """
|
CIRCUITTYPE_ACTIONS = """
|
||||||
{% if perms.circuit.change_circuittype %}
|
{% if perms.circuit.change_circuittype %}
|
||||||
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STATUS_LABEL = """
|
||||||
|
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationColumn(tables.Column):
|
||||||
|
|
||||||
|
def render(self, value):
|
||||||
|
if value.interface:
|
||||||
|
return mark_safe('<a href="{}" title="{}">{}</a>'.format(
|
||||||
|
value.interface.device.get_absolute_url(),
|
||||||
|
value.site,
|
||||||
|
value.interface.device
|
||||||
|
))
|
||||||
|
return mark_safe('<a href="{}">{}</a>'.format(
|
||||||
|
value.site.get_absolute_url(),
|
||||||
|
value.site
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Providers
|
# Providers
|
||||||
@@ -60,16 +80,11 @@ class CircuitTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
cid = tables.LinkColumn(verbose_name='ID')
|
cid = tables.LinkColumn(verbose_name='ID')
|
||||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
a_side = tables.LinkColumn(
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
'dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
|
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
|
||||||
args=[Accessor('termination_a.site.slug')]
|
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
|
||||||
)
|
|
||||||
z_side = tables.LinkColumn(
|
|
||||||
'dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
|
|
||||||
args=[Accessor('termination_z.site.slug')]
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
|
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from circuits.constants import TERM_SIDE_A, TERM_SIDE_Z
|
||||||
|
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
from extras.constants import GRAPH_TYPE_PROVIDER
|
||||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
|
from extras.models import Graph
|
||||||
from users.models import Token
|
from users.models import Token
|
||||||
from utilities.tests import HttpStatusMixin
|
from utilities.tests import HttpStatusMixin
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:provider-list')
|
url = reverse('circuits-api:provider-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Provider.objects.count(), 4)
|
self.assertEqual(Provider.objects.count(), 4)
|
||||||
@@ -76,6 +77,32 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(provider4.name, data['name'])
|
self.assertEqual(provider4.name, data['name'])
|
||||||
self.assertEqual(provider4.slug, data['slug'])
|
self.assertEqual(provider4.slug, data['slug'])
|
||||||
|
|
||||||
|
def test_create_provider_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Provider 4',
|
||||||
|
'slug': 'test-provider-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Provider 5',
|
||||||
|
'slug': 'test-provider-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Provider 6',
|
||||||
|
'slug': 'test-provider-6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('circuits-api:provider-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Provider.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_provider(self):
|
def test_update_provider(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -84,7 +111,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Provider.objects.count(), 3)
|
self.assertEqual(Provider.objects.count(), 3)
|
||||||
@@ -135,7 +162,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittype-list')
|
url = reverse('circuits-api:circuittype-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(CircuitType.objects.count(), 4)
|
self.assertEqual(CircuitType.objects.count(), 4)
|
||||||
@@ -151,7 +178,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(CircuitType.objects.count(), 3)
|
self.assertEqual(CircuitType.objects.count(), 3)
|
||||||
@@ -207,7 +234,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuit-list')
|
url = reverse('circuits-api:circuit-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Circuit.objects.count(), 4)
|
self.assertEqual(Circuit.objects.count(), 4)
|
||||||
@@ -216,6 +243,35 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(circuit4.provider_id, data['provider'])
|
self.assertEqual(circuit4.provider_id, data['provider'])
|
||||||
self.assertEqual(circuit4.type_id, data['type'])
|
self.assertEqual(circuit4.type_id, data['type'])
|
||||||
|
|
||||||
|
def test_create_circuit_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'cid': 'TEST0004',
|
||||||
|
'provider': self.provider1.pk,
|
||||||
|
'type': self.circuittype1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'cid': 'TEST0005',
|
||||||
|
'provider': self.provider1.pk,
|
||||||
|
'type': self.circuittype1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'cid': 'TEST0006',
|
||||||
|
'provider': self.provider1.pk,
|
||||||
|
'type': self.circuittype1.pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('circuits-api:circuit-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Circuit.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
|
||||||
|
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
|
||||||
|
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
|
||||||
|
|
||||||
def test_update_circuit(self):
|
def test_update_circuit(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -225,7 +281,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Circuit.objects.count(), 3)
|
self.assertEqual(Circuit.objects.count(), 3)
|
||||||
@@ -292,7 +348,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittermination-list')
|
url = reverse('circuits-api:circuittermination-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(CircuitTermination.objects.count(), 4)
|
self.assertEqual(CircuitTermination.objects.count(), 4)
|
||||||
@@ -312,7 +368,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.conf.urls import url
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
app_name = 'circuits'
|
app_name = 'circuits'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ urlpatterns = [
|
|||||||
# Circuit types
|
# Circuit types
|
||||||
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
|
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
|
||||||
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
|
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
|
||||||
|
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
|
||||||
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
|
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
|
||||||
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
|
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from utilities.views import (
|
|||||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
)
|
)
|
||||||
from . import filters, forms, tables
|
from . import filters, forms, tables
|
||||||
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
|
from .constants import TERM_SIDE_A, TERM_SIDE_Z
|
||||||
|
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -52,7 +53,7 @@ class ProviderView(View):
|
|||||||
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
|
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'circuits.add_provider'
|
permission_required = 'circuits.add_provider'
|
||||||
model = Provider
|
model = Provider
|
||||||
form_class = forms.ProviderForm
|
model_form = forms.ProviderForm
|
||||||
template_name = 'circuits/provider_edit.html'
|
template_name = 'circuits/provider_edit.html'
|
||||||
default_return_url = 'circuits:provider_list'
|
default_return_url = 'circuits:provider_list'
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ class CircuitTypeListView(ObjectListView):
|
|||||||
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
|
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'circuits.add_circuittype'
|
permission_required = 'circuits.add_circuittype'
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
form_class = forms.CircuitTypeForm
|
model_form = forms.CircuitTypeForm
|
||||||
|
|
||||||
def get_return_url(self, request, obj):
|
def get_return_url(self, request, obj):
|
||||||
return reverse('circuits:circuittype_list')
|
return reverse('circuits:circuittype_list')
|
||||||
@@ -114,6 +115,13 @@ class CircuitTypeEditView(CircuitTypeCreateView):
|
|||||||
permission_required = 'circuits.change_circuittype'
|
permission_required = 'circuits.change_circuittype'
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||||
|
permission_required = 'circuits.add_circuittype'
|
||||||
|
model_form = forms.CircuitTypeCSVForm
|
||||||
|
table = tables.CircuitTypeTable
|
||||||
|
default_return_url = 'circuits:circuittype_list'
|
||||||
|
|
||||||
|
|
||||||
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||||
permission_required = 'circuits.delete_circuittype'
|
permission_required = 'circuits.delete_circuittype'
|
||||||
cls = CircuitType
|
cls = CircuitType
|
||||||
@@ -127,7 +135,11 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class CircuitListView(ObjectListView):
|
class CircuitListView(ObjectListView):
|
||||||
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
|
queryset = Circuit.objects.select_related(
|
||||||
|
'provider', 'type', 'tenant'
|
||||||
|
).prefetch_related(
|
||||||
|
'terminations__site', 'terminations__interface__device'
|
||||||
|
)
|
||||||
filter = filters.CircuitFilter
|
filter = filters.CircuitFilter
|
||||||
filter_form = forms.CircuitFilterForm
|
filter_form = forms.CircuitFilterForm
|
||||||
table = tables.CircuitTable
|
table = tables.CircuitTable
|
||||||
@@ -160,7 +172,7 @@ class CircuitView(View):
|
|||||||
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
|
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'circuits.add_circuit'
|
permission_required = 'circuits.add_circuit'
|
||||||
model = Circuit
|
model = Circuit
|
||||||
form_class = forms.CircuitForm
|
model_form = forms.CircuitForm
|
||||||
template_name = 'circuits/circuit_edit.html'
|
template_name = 'circuits/circuit_edit.html'
|
||||||
default_return_url = 'circuits:circuit_list'
|
default_return_url = 'circuits:circuit_list'
|
||||||
|
|
||||||
@@ -253,7 +265,7 @@ def circuit_terminations_swap(request, pk):
|
|||||||
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
|
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'circuits.add_circuittermination'
|
permission_required = 'circuits.add_circuittermination'
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
form_class = forms.CircuitTerminationForm
|
model_form = forms.CircuitTerminationForm
|
||||||
template_name = 'circuits/circuittermination_edit.html'
|
template_name = 'circuits/circuittermination_edit.html'
|
||||||
|
|
||||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.validators import UniqueTogetherValidator
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from ipam.models import IPAddress
|
|
||||||
from circuits.models import Circuit, CircuitTermination
|
from circuits.models import Circuit, CircuitTermination
|
||||||
|
from dcim.constants import (
|
||||||
|
CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
|
||||||
|
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||||
|
)
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
|
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
|
||||||
)
|
)
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
|
from ipam.models import IPAddress, VLAN
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
from users.api.serializers import NestedUserSerializer
|
||||||
|
from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
|
||||||
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -50,15 +56,18 @@ class WritableRegionSerializer(ValidatedModelSerializer):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class SiteSerializer(CustomFieldModelSerializer):
|
class SiteSerializer(CustomFieldModelSerializer):
|
||||||
|
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
|
||||||
region = NestedRegionSerializer()
|
region = NestedRegionSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
|
time_zone = TimeZoneField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Site
|
model = Site
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
|
||||||
|
'count_circuits',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -71,12 +80,14 @@ class NestedSiteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class WritableSiteSerializer(CustomFieldModelSerializer):
|
class WritableSiteSerializer(CustomFieldModelSerializer):
|
||||||
|
time_zone = TimeZoneField(required=False, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Site
|
model = Site
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
|
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -141,8 +152,8 @@ class RackSerializer(CustomFieldModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Rack
|
model = Rack
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height',
|
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
|
||||||
'desc_units', 'comments', 'custom_fields',
|
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -159,8 +170,8 @@ class WritableRackSerializer(CustomFieldModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Rack
|
model = Rack
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
||||||
'comments', 'custom_fields',
|
'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||||
# prevents facility_id from being interpreted as a required field.
|
# prevents facility_id from being interpreted as a required field.
|
||||||
@@ -210,17 +221,19 @@ class RackUnitSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class RackReservationSerializer(serializers.ModelSerializer):
|
class RackReservationSerializer(serializers.ModelSerializer):
|
||||||
rack = NestedRackSerializer()
|
rack = NestedRackSerializer()
|
||||||
|
user = NestedUserSerializer()
|
||||||
|
tenant = NestedTenantSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
|
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
|
||||||
|
|
||||||
|
|
||||||
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ['id', 'rack', 'units', 'description']
|
fields = ['id', 'rack', 'units', 'user', 'tenant', 'description']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -403,7 +416,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceRole
|
model = DeviceRole
|
||||||
fields = ['id', 'name', 'slug', 'color']
|
fields = ['id', 'name', 'slug', 'color', 'vm_role']
|
||||||
|
|
||||||
|
|
||||||
class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
||||||
@@ -418,11 +431,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
|||||||
# Platforms
|
# Platforms
|
||||||
#
|
#
|
||||||
|
|
||||||
class PlatformSerializer(ValidatedModelSerializer):
|
class PlatformSerializer(serializers.ModelSerializer):
|
||||||
|
manufacturer = NestedManufacturerSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
|
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||||
|
|
||||||
|
|
||||||
class NestedPlatformSerializer(serializers.ModelSerializer):
|
class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||||
@@ -433,6 +447,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['id', 'url', 'name', 'slug']
|
fields = ['id', 'url', 'name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class WritablePlatformSerializer(ValidatedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Platform
|
||||||
|
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Devices
|
# Devices
|
||||||
#
|
#
|
||||||
@@ -446,6 +467,25 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['id', 'url', 'family', 'address']
|
fields = ['id', 'url', 'family', 'address']
|
||||||
|
|
||||||
|
|
||||||
|
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
|
||||||
|
class NestedClusterSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Cluster
|
||||||
|
fields = ['id', 'url', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
# Cannot import NestedVirtualChassisSerializer due to circular dependency
|
||||||
|
class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||||
|
master = NestedDeviceSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['id', 'url', 'master']
|
||||||
|
|
||||||
|
|
||||||
class DeviceSerializer(CustomFieldModelSerializer):
|
class DeviceSerializer(CustomFieldModelSerializer):
|
||||||
device_type = NestedDeviceTypeSerializer()
|
device_type = NestedDeviceTypeSerializer()
|
||||||
device_role = NestedDeviceRoleSerializer()
|
device_role = NestedDeviceRoleSerializer()
|
||||||
@@ -454,18 +494,21 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
|||||||
site = NestedSiteSerializer()
|
site = NestedSiteSerializer()
|
||||||
rack = NestedRackSerializer()
|
rack = NestedRackSerializer()
|
||||||
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
||||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
|
||||||
primary_ip = DeviceIPAddressSerializer()
|
primary_ip = DeviceIPAddressSerializer()
|
||||||
primary_ip4 = DeviceIPAddressSerializer()
|
primary_ip4 = DeviceIPAddressSerializer()
|
||||||
primary_ip6 = DeviceIPAddressSerializer()
|
primary_ip6 = DeviceIPAddressSerializer()
|
||||||
parent_device = serializers.SerializerMethodField()
|
parent_device = serializers.SerializerMethodField()
|
||||||
|
cluster = NestedClusterSerializer()
|
||||||
|
virtual_chassis = DeviceVirtualChassisSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||||
'comments', 'custom_fields',
|
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_parent_device(self, obj):
|
def get_parent_device(self, obj):
|
||||||
@@ -485,7 +528,8 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
|
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||||
|
'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
@@ -613,6 +657,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
|
||||||
|
class InterfaceVLANSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VLAN
|
||||||
|
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||||
|
|
||||||
|
|
||||||
class InterfaceSerializer(serializers.ModelSerializer):
|
class InterfaceSerializer(serializers.ModelSerializer):
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||||
@@ -620,12 +673,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
|||||||
is_connected = serializers.SerializerMethodField(read_only=True)
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
interface_connection = serializers.SerializerMethodField(read_only=True)
|
interface_connection = serializers.SerializerMethodField(read_only=True)
|
||||||
circuit_termination = InterfaceCircuitTerminationSerializer()
|
circuit_termination = InterfaceCircuitTerminationSerializer()
|
||||||
|
untagged_vlan = InterfaceVLANSerializer()
|
||||||
|
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
|
||||||
|
tagged_vlans = InterfaceVLANSerializer(many=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||||
'is_connected', 'interface_connection', 'circuit_termination',
|
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_is_connected(self, obj):
|
def get_is_connected(self, obj):
|
||||||
@@ -670,8 +726,28 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||||
|
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
# All associated VLANs be global or assigned to the parent device's site.
|
||||||
|
device = self.instance.device if self.instance else data.get('device')
|
||||||
|
untagged_vlan = data.get('untagged_vlan')
|
||||||
|
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
|
||||||
|
"global.".format(untagged_vlan)
|
||||||
|
})
|
||||||
|
for vlan in data.get('tagged_vlans', []):
|
||||||
|
if vlan.site not in [device.site, None]:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||||
|
"be global.".format(vlan)
|
||||||
|
})
|
||||||
|
|
||||||
|
return super(WritableInterfaceSerializer, self).validate(data)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Device bays
|
# Device bays
|
||||||
@@ -718,6 +794,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class WritableInventoryItemSerializer(ValidatedModelSerializer):
|
class WritableInventoryItemSerializer(ValidatedModelSerializer):
|
||||||
|
# Provide a default value to satisfy UniqueTogetherValidator
|
||||||
|
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
@@ -754,3 +832,30 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = InterfaceConnection
|
model = InterfaceConnection
|
||||||
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Virtual chassis
|
||||||
|
#
|
||||||
|
|
||||||
|
class VirtualChassisSerializer(serializers.ModelSerializer):
|
||||||
|
master = NestedDeviceSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['id', 'master', 'domain']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedVirtualChassisSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['id', 'url']
|
||||||
|
|
||||||
|
|
||||||
|
class WritableVirtualChassisSerializer(ValidatedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['id', 'master', 'domain']
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class DCIMRootView(routers.APIRootView):
|
|||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.APIRootView = DCIMRootView
|
router.APIRootView = DCIMRootView
|
||||||
|
|
||||||
|
# Field choices
|
||||||
|
router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice')
|
||||||
|
|
||||||
# Sites
|
# Sites
|
||||||
router.register(r'regions', views.RegionViewSet)
|
router.register(r'regions', views.RegionViewSet)
|
||||||
router.register(r'sites', views.SiteViewSet)
|
router.register(r'sites', views.SiteViewSet)
|
||||||
@@ -57,6 +60,9 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
|
|||||||
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
||||||
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||||
|
|
||||||
|
# Virtual chassis
|
||||||
|
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,55 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
from rest_framework.decorators import detail_route
|
from collections import OrderedDict
|
||||||
from rest_framework.mixins import ListModelMixin
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from drf_yasg.openapi import Parameter
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from rest_framework.decorators import detail_route
|
||||||
|
from rest_framework.mixins import ListModelMixin
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||||
|
|
||||||
|
from dcim import filters
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
RackReservation, RackRole, Region, Site,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
from dcim import filters
|
|
||||||
from extras.api.serializers import RenderedGraphSerializer
|
from extras.api.serializers import RenderedGraphSerializer
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ServiceUnavailable, WritableSerializerMixin
|
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
|
||||||
from .exceptions import MissingFilterException
|
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
from .exceptions import MissingFilterException
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Field choices
|
||||||
|
#
|
||||||
|
|
||||||
|
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||||
|
fields = (
|
||||||
|
(Device, ['face', 'status']),
|
||||||
|
(ConsolePort, ['connection_status']),
|
||||||
|
(Interface, ['form_factor', 'mode']),
|
||||||
|
(InterfaceConnection, ['connection_status']),
|
||||||
|
(InterfaceTemplate, ['form_factor']),
|
||||||
|
(PowerPort, ['connection_status']),
|
||||||
|
(Rack, ['type', 'width']),
|
||||||
|
(Site, ['status']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Regions
|
# Regions
|
||||||
#
|
#
|
||||||
|
|
||||||
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
class RegionViewSet(ModelViewSet):
|
||||||
queryset = Region.objects.all()
|
queryset = Region.objects.all()
|
||||||
serializer_class = serializers.RegionSerializer
|
serializer_class = serializers.RegionSerializer
|
||||||
write_serializer_class = serializers.WritableRegionSerializer
|
write_serializer_class = serializers.WritableRegionSerializer
|
||||||
@@ -40,7 +60,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# Sites
|
# Sites
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class SiteViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Site.objects.select_related('region', 'tenant')
|
queryset = Site.objects.select_related('region', 'tenant')
|
||||||
serializer_class = serializers.SiteSerializer
|
serializer_class = serializers.SiteSerializer
|
||||||
write_serializer_class = serializers.WritableSiteSerializer
|
write_serializer_class = serializers.WritableSiteSerializer
|
||||||
@@ -61,7 +81,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Rack groups
|
# Rack groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
class RackGroupViewSet(ModelViewSet):
|
||||||
queryset = RackGroup.objects.select_related('site')
|
queryset = RackGroup.objects.select_related('site')
|
||||||
serializer_class = serializers.RackGroupSerializer
|
serializer_class = serializers.RackGroupSerializer
|
||||||
write_serializer_class = serializers.WritableRackGroupSerializer
|
write_serializer_class = serializers.WritableRackGroupSerializer
|
||||||
@@ -82,7 +102,7 @@ class RackRoleViewSet(ModelViewSet):
|
|||||||
# Racks
|
# Racks
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class RackViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||||
serializer_class = serializers.RackSerializer
|
serializer_class = serializers.RackSerializer
|
||||||
write_serializer_class = serializers.WritableRackSerializer
|
write_serializer_class = serializers.WritableRackSerializer
|
||||||
@@ -113,8 +133,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Rack reservations
|
# Rack reservations
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
|
class RackReservationViewSet(ModelViewSet):
|
||||||
queryset = RackReservation.objects.select_related('rack')
|
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
|
||||||
serializer_class = serializers.RackReservationSerializer
|
serializer_class = serializers.RackReservationSerializer
|
||||||
write_serializer_class = serializers.WritableRackReservationSerializer
|
write_serializer_class = serializers.WritableRackReservationSerializer
|
||||||
filter_class = filters.RackReservationFilter
|
filter_class = filters.RackReservationFilter
|
||||||
@@ -138,7 +158,7 @@ class ManufacturerViewSet(ModelViewSet):
|
|||||||
# Device types
|
# Device types
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||||
queryset = DeviceType.objects.select_related('manufacturer')
|
queryset = DeviceType.objects.select_related('manufacturer')
|
||||||
serializer_class = serializers.DeviceTypeSerializer
|
serializer_class = serializers.DeviceTypeSerializer
|
||||||
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
||||||
@@ -149,42 +169,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Device type components
|
# Device type components
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsolePortTemplateViewSet(ModelViewSet):
|
||||||
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
||||||
filter_class = filters.ConsolePortTemplateFilter
|
filter_class = filters.ConsolePortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsoleServerPortTemplateViewSet(ModelViewSet):
|
||||||
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
||||||
filter_class = filters.ConsoleServerPortTemplateFilter
|
filter_class = filters.ConsoleServerPortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerPortTemplateViewSet(ModelViewSet):
|
||||||
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.PowerPortTemplateSerializer
|
serializer_class = serializers.PowerPortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
||||||
filter_class = filters.PowerPortTemplateFilter
|
filter_class = filters.PowerPortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerOutletTemplateViewSet(ModelViewSet):
|
||||||
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||||
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
||||||
filter_class = filters.PowerOutletTemplateFilter
|
filter_class = filters.PowerOutletTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceTemplateViewSet(ModelViewSet):
|
||||||
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.InterfaceTemplateSerializer
|
serializer_class = serializers.InterfaceTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
||||||
filter_class = filters.InterfaceTemplateFilter
|
filter_class = filters.InterfaceTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class DeviceBayTemplateViewSet(ModelViewSet):
|
||||||
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
||||||
@@ -208,6 +228,7 @@ class DeviceRoleViewSet(ModelViewSet):
|
|||||||
class PlatformViewSet(ModelViewSet):
|
class PlatformViewSet(ModelViewSet):
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.all()
|
||||||
serializer_class = serializers.PlatformSerializer
|
serializer_class = serializers.PlatformSerializer
|
||||||
|
write_serializer_class = serializers.WritablePlatformSerializer
|
||||||
filter_class = filters.PlatformFilter
|
filter_class = filters.PlatformFilter
|
||||||
|
|
||||||
|
|
||||||
@@ -215,9 +236,10 @@ class PlatformViewSet(ModelViewSet):
|
|||||||
# Devices
|
# Devices
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class DeviceViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Device.objects.select_related(
|
queryset = Device.objects.select_related(
|
||||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||||
|
'virtual_chassis__master',
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||||
)
|
)
|
||||||
@@ -240,12 +262,14 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
device.platform
|
device.platform
|
||||||
))
|
))
|
||||||
|
|
||||||
# Check that NAPALM is installed and verify the configured driver
|
# Check that NAPALM is installed
|
||||||
try:
|
try:
|
||||||
import napalm
|
import napalm
|
||||||
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||||
|
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
|
||||||
|
|
||||||
|
# Validate the configured driver
|
||||||
try:
|
try:
|
||||||
driver = napalm.get_network_driver(device.platform.napalm_driver)
|
driver = napalm.get_network_driver(device.platform.napalm_driver)
|
||||||
except ModuleImportError:
|
except ModuleImportError:
|
||||||
@@ -291,35 +315,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Device components
|
# Device components
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsolePortViewSet(ModelViewSet):
|
||||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||||
serializer_class = serializers.ConsolePortSerializer
|
serializer_class = serializers.ConsolePortSerializer
|
||||||
write_serializer_class = serializers.WritableConsolePortSerializer
|
write_serializer_class = serializers.WritableConsolePortSerializer
|
||||||
filter_class = filters.ConsolePortFilter
|
filter_class = filters.ConsolePortFilter
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsoleServerPortViewSet(ModelViewSet):
|
||||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||||
serializer_class = serializers.ConsoleServerPortSerializer
|
serializer_class = serializers.ConsoleServerPortSerializer
|
||||||
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
||||||
filter_class = filters.ConsoleServerPortFilter
|
filter_class = filters.ConsoleServerPortFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerPortViewSet(ModelViewSet):
|
||||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||||
serializer_class = serializers.PowerPortSerializer
|
serializer_class = serializers.PowerPortSerializer
|
||||||
write_serializer_class = serializers.WritablePowerPortSerializer
|
write_serializer_class = serializers.WritablePowerPortSerializer
|
||||||
filter_class = filters.PowerPortFilter
|
filter_class = filters.PowerPortFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerOutletViewSet(ModelViewSet):
|
||||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||||
serializer_class = serializers.PowerOutletSerializer
|
serializer_class = serializers.PowerOutletSerializer
|
||||||
write_serializer_class = serializers.WritablePowerOutletSerializer
|
write_serializer_class = serializers.WritablePowerOutletSerializer
|
||||||
filter_class = filters.PowerOutletFilter
|
filter_class = filters.PowerOutletFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceViewSet(ModelViewSet):
|
||||||
queryset = Interface.objects.select_related('device')
|
queryset = Interface.objects.select_related('device')
|
||||||
serializer_class = serializers.InterfaceSerializer
|
serializer_class = serializers.InterfaceSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||||
@@ -336,14 +360,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
|
class DeviceBayViewSet(ModelViewSet):
|
||||||
queryset = DeviceBay.objects.select_related('installed_device')
|
queryset = DeviceBay.objects.select_related('installed_device')
|
||||||
serializer_class = serializers.DeviceBaySerializer
|
serializer_class = serializers.DeviceBaySerializer
|
||||||
write_serializer_class = serializers.WritableDeviceBaySerializer
|
write_serializer_class = serializers.WritableDeviceBaySerializer
|
||||||
filter_class = filters.DeviceBayFilter
|
filter_class = filters.DeviceBayFilter
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
|
class InventoryItemViewSet(ModelViewSet):
|
||||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||||
serializer_class = serializers.InventoryItemSerializer
|
serializer_class = serializers.InventoryItemSerializer
|
||||||
write_serializer_class = serializers.WritableInventoryItemSerializer
|
write_serializer_class = serializers.WritableInventoryItemSerializer
|
||||||
@@ -366,13 +390,23 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
|||||||
filter_class = filters.PowerConnectionFilter
|
filter_class = filters.PowerConnectionFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceConnectionViewSet(ModelViewSet):
|
||||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||||
serializer_class = serializers.InterfaceConnectionSerializer
|
serializer_class = serializers.InterfaceConnectionSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||||
filter_class = filters.InterfaceConnectionFilter
|
filter_class = filters.InterfaceConnectionFilter
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Virtual chassis
|
||||||
|
#
|
||||||
|
|
||||||
|
class VirtualChassisViewSet(ModelViewSet):
|
||||||
|
queryset = VirtualChassis.objects.all()
|
||||||
|
serializer_class = serializers.VirtualChassisSerializer
|
||||||
|
write_serializer_class = serializers.WritableVirtualChassisSerializer
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
#
|
#
|
||||||
@@ -387,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
|
|||||||
* `peer-interface`: The name of the peer interface
|
* `peer-interface`: The name of the peer interface
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||||
|
_device_param = Parameter('peer-device', 'query',
|
||||||
|
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
|
||||||
|
_interface_param = Parameter('peer-interface', 'query',
|
||||||
|
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
|
||||||
|
|
||||||
def get_view_name(self):
|
def get_view_name(self):
|
||||||
return "Connected Device Locator"
|
return "Connected Device Locator"
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
|
||||||
def list(self, request):
|
def list(self, request):
|
||||||
|
|
||||||
peer_device_name = request.query_params.get('peer-device')
|
peer_device_name = request.query_params.get(self._device_param.name)
|
||||||
peer_interface_name = request.query_params.get('peer-interface')
|
peer_interface_name = request.query_params.get(self._interface_param.name)
|
||||||
if not peer_device_name or not peer_interface_name:
|
if not peer_device_name or not peer_interface_name:
|
||||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ from django.apps import AppConfig
|
|||||||
class DCIMConfig(AppConfig):
|
class DCIMConfig(AppConfig):
|
||||||
name = "dcim"
|
name = "dcim"
|
||||||
verbose_name = "DCIM"
|
verbose_name = "DCIM"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import dcim.signals
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ IFACE_FF_10GE_X2 = 1320
|
|||||||
IFACE_FF_25GE_SFP28 = 1350
|
IFACE_FF_25GE_SFP28 = 1350
|
||||||
IFACE_FF_40GE_QSFP_PLUS = 1400
|
IFACE_FF_40GE_QSFP_PLUS = 1400
|
||||||
IFACE_FF_100GE_CFP = 1500
|
IFACE_FF_100GE_CFP = 1500
|
||||||
|
IFACE_FF_100GE_CFP2 = 1510
|
||||||
|
IFACE_FF_100GE_CFP4 = 1520
|
||||||
|
IFACE_FF_100GE_CPAK = 1550
|
||||||
IFACE_FF_100GE_QSFP28 = 1600
|
IFACE_FF_100GE_QSFP28 = 1600
|
||||||
# Wireless
|
# Wireless
|
||||||
IFACE_FF_80211A = 2600
|
IFACE_FF_80211A = 2600
|
||||||
@@ -99,7 +102,7 @@ IFACE_FF_CHOICES = [
|
|||||||
[
|
[
|
||||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||||
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'Ethernet (fixed)',
|
'Ethernet (fixed)',
|
||||||
@@ -122,6 +125,9 @@ IFACE_FF_CHOICES = [
|
|||||||
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
|
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
|
||||||
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
||||||
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
|
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
|
||||||
|
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
|
||||||
|
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
|
||||||
|
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
|
||||||
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
|
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
@@ -187,24 +193,43 @@ WIRELESS_IFACE_TYPES = [
|
|||||||
|
|
||||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||||
|
|
||||||
# Device statuses
|
IFACE_MODE_ACCESS = 100
|
||||||
STATUS_OFFLINE = 0
|
IFACE_MODE_TAGGED = 200
|
||||||
STATUS_ACTIVE = 1
|
IFACE_MODE_TAGGED_ALL = 300
|
||||||
STATUS_PLANNED = 2
|
IFACE_MODE_CHOICES = [
|
||||||
STATUS_STAGED = 3
|
[IFACE_MODE_ACCESS, 'Access'],
|
||||||
STATUS_FAILED = 4
|
[IFACE_MODE_TAGGED, 'Tagged'],
|
||||||
STATUS_INVENTORY = 5
|
[IFACE_MODE_TAGGED_ALL, 'Tagged All'],
|
||||||
STATUS_CHOICES = [
|
|
||||||
[STATUS_ACTIVE, 'Active'],
|
|
||||||
[STATUS_OFFLINE, 'Offline'],
|
|
||||||
[STATUS_PLANNED, 'Planned'],
|
|
||||||
[STATUS_STAGED, 'Staged'],
|
|
||||||
[STATUS_FAILED, 'Failed'],
|
|
||||||
[STATUS_INVENTORY, 'Inventory'],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Bootstrap CSS classes for device stasuses
|
# Device statuses
|
||||||
DEVICE_STATUS_CLASSES = {
|
DEVICE_STATUS_OFFLINE = 0
|
||||||
|
DEVICE_STATUS_ACTIVE = 1
|
||||||
|
DEVICE_STATUS_PLANNED = 2
|
||||||
|
DEVICE_STATUS_STAGED = 3
|
||||||
|
DEVICE_STATUS_FAILED = 4
|
||||||
|
DEVICE_STATUS_INVENTORY = 5
|
||||||
|
DEVICE_STATUS_CHOICES = [
|
||||||
|
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||||
|
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||||
|
[DEVICE_STATUS_PLANNED, 'Planned'],
|
||||||
|
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||||
|
[DEVICE_STATUS_FAILED, 'Failed'],
|
||||||
|
[DEVICE_STATUS_INVENTORY, 'Inventory'],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Site statuses
|
||||||
|
SITE_STATUS_ACTIVE = 1
|
||||||
|
SITE_STATUS_PLANNED = 2
|
||||||
|
SITE_STATUS_RETIRED = 4
|
||||||
|
SITE_STATUS_CHOICES = [
|
||||||
|
[SITE_STATUS_ACTIVE, 'Active'],
|
||||||
|
[SITE_STATUS_PLANNED, 'Planned'],
|
||||||
|
[SITE_STATUS_RETIRED, 'Retired'],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Bootstrap CSS classes for device statuses
|
||||||
|
STATUS_CLASSES = {
|
||||||
0: 'warning',
|
0: 'warning',
|
||||||
1: 'success',
|
1: 'success',
|
||||||
2: 'info',
|
2: 'info',
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q
|
||||||
from netaddr import EUI
|
from netaddr import EUI
|
||||||
from netaddr.core import AddrFormatError
|
from netaddr.core import AddrFormatError
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from extras.filters import CustomFieldFilterSet
|
from extras.filters import CustomFieldFilterSet
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
|
from utilities.filters import NullableCharFieldFilter, NumericInFilter
|
||||||
|
from virtualization.models import Cluster
|
||||||
|
from .constants import (
|
||||||
|
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
|
||||||
|
WIRELESS_IFACE_TYPES,
|
||||||
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InterfaceTemplate, Manufacturer, InventoryItem, NONCONNECTABLE_IFACE_TYPES, Platform, PowerOutlet,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RegionFilter(django_filters.FilterSet):
|
class RegionFilter(django_filters.FilterSet):
|
||||||
parent_id = NullableModelMultipleChoiceFilter(
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
label='Parent region (ID)',
|
label='Parent region (ID)',
|
||||||
)
|
)
|
||||||
parent = NullableModelMultipleChoiceFilter(
|
parent = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='parent__slug',
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Parent region (slug)',
|
label='Parent region (slug)',
|
||||||
@@ -34,6 +42,15 @@ class RegionFilter(django_filters.FilterSet):
|
|||||||
model = Region
|
model = Region
|
||||||
fields = ['name', 'slug']
|
fields = ['name', 'slug']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = (
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(slug__icontains=value)
|
||||||
|
)
|
||||||
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||||
@@ -41,20 +58,26 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
region_id = NullableModelMultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=SITE_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
label='Region (ID)',
|
label='Region (ID)',
|
||||||
)
|
)
|
||||||
region = NullableModelMultipleChoiceFilter(
|
region = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='region__slug',
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Region (slug)',
|
label='Region (slug)',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
@@ -70,6 +93,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
qs_filter = (
|
qs_filter = (
|
||||||
Q(name__icontains=value) |
|
Q(name__icontains=value) |
|
||||||
Q(facility__icontains=value) |
|
Q(facility__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
Q(physical_address__icontains=value) |
|
Q(physical_address__icontains=value) |
|
||||||
Q(shipping_address__icontains=value) |
|
Q(shipping_address__icontains=value) |
|
||||||
Q(contact_name__icontains=value) |
|
Q(contact_name__icontains=value) |
|
||||||
@@ -125,32 +149,32 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
)
|
)
|
||||||
group_id = NullableModelMultipleChoiceFilter(
|
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=RackGroup.objects.all(),
|
queryset=RackGroup.objects.all(),
|
||||||
label='Group (ID)',
|
label='Group (ID)',
|
||||||
)
|
)
|
||||||
group = NullableModelMultipleChoiceFilter(
|
group = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='group',
|
name='group__slug',
|
||||||
queryset=RackGroup.objects.all(),
|
queryset=RackGroup.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Group',
|
label='Group',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
role_id = NullableModelMultipleChoiceFilter(
|
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=RackRole.objects.all(),
|
queryset=RackRole.objects.all(),
|
||||||
label='Role (ID)',
|
label='Role (ID)',
|
||||||
)
|
)
|
||||||
role = NullableModelMultipleChoiceFilter(
|
role = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='role',
|
name='role__slug',
|
||||||
queryset=RackRole.objects.all(),
|
queryset=RackRole.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Role (slug)',
|
label='Role (slug)',
|
||||||
@@ -158,7 +182,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Rack
|
model = Rack
|
||||||
fields = ['type', 'width', 'u_height', 'desc_units']
|
fields = ['name', 'serial', 'type', 'width', 'u_height', 'desc_units']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@@ -166,6 +190,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(name__icontains=value) |
|
Q(name__icontains=value) |
|
||||||
Q(facility_id__icontains=value) |
|
Q(facility_id__icontains=value) |
|
||||||
|
Q(serial__icontains=value.strip()) |
|
||||||
Q(comments__icontains=value)
|
Q(comments__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,17 +216,27 @@ class RackReservationFilter(django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
)
|
)
|
||||||
group_id = NullableModelMultipleChoiceFilter(
|
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='rack__group',
|
name='rack__group',
|
||||||
queryset=RackGroup.objects.all(),
|
queryset=RackGroup.objects.all(),
|
||||||
label='Group (ID)',
|
label='Group (ID)',
|
||||||
)
|
)
|
||||||
group = NullableModelMultipleChoiceFilter(
|
group = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='rack__group',
|
name='rack__group__slug',
|
||||||
queryset=RackGroup.objects.all(),
|
queryset=RackGroup.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Group',
|
label='Group',
|
||||||
)
|
)
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
label='Tenant (ID)',
|
||||||
|
)
|
||||||
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='tenant__slug',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Tenant (slug)',
|
||||||
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
label='User (ID)',
|
label='User (ID)',
|
||||||
@@ -324,10 +359,21 @@ class DeviceRoleFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceRole
|
model = DeviceRole
|
||||||
fields = ['name', 'slug', 'color']
|
fields = ['name', 'slug', 'color', 'vm_role']
|
||||||
|
|
||||||
|
|
||||||
class PlatformFilter(django_filters.FilterSet):
|
class PlatformFilter(django_filters.FilterSet):
|
||||||
|
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='manufacturer',
|
||||||
|
queryset=Manufacturer.objects.all(),
|
||||||
|
label='Manufacturer (ID)',
|
||||||
|
)
|
||||||
|
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='manufacturer__slug',
|
||||||
|
queryset=Manufacturer.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Manufacturer (slug)',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
@@ -366,22 +412,22 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Role (slug)',
|
label='Role (slug)',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
platform_id = NullableModelMultipleChoiceFilter(
|
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
label='Platform (ID)',
|
label='Platform (ID)',
|
||||||
)
|
)
|
||||||
platform = NullableModelMultipleChoiceFilter(
|
platform = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='platform',
|
name='platform__slug',
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Platform (slug)',
|
label='Platform (slug)',
|
||||||
@@ -403,11 +449,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
queryset=RackGroup.objects.all(),
|
queryset=RackGroup.objects.all(),
|
||||||
label='Rack group (ID)',
|
label='Rack group (ID)',
|
||||||
)
|
)
|
||||||
rack_id = NullableModelMultipleChoiceFilter(
|
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='rack',
|
name='rack',
|
||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
label='Rack (ID)',
|
label='Rack (ID)',
|
||||||
)
|
)
|
||||||
|
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
label='VM cluster (ID)',
|
||||||
|
)
|
||||||
model = django_filters.ModelMultipleChoiceFilter(
|
model = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='device_type__slug',
|
name='device_type__slug',
|
||||||
queryset=DeviceType.objects.all(),
|
queryset=DeviceType.objects.all(),
|
||||||
@@ -415,7 +465,8 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
label='Device model (slug)',
|
label='Device model (slug)',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=STATUS_CHOICES
|
choices=DEVICE_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
)
|
)
|
||||||
is_full_depth = django_filters.BooleanFilter(
|
is_full_depth = django_filters.BooleanFilter(
|
||||||
name='device_type__is_full_depth',
|
name='device_type__is_full_depth',
|
||||||
@@ -441,10 +492,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='_has_primary_ip',
|
method='_has_primary_ip',
|
||||||
label='Has a primary IP',
|
label='Has a primary IP',
|
||||||
)
|
)
|
||||||
|
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='virtual_chassis',
|
||||||
|
queryset=VirtualChassis.objects.all(),
|
||||||
|
label='Virtual chassis (ID)',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ['serial']
|
fields = ['serial', 'position']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@@ -556,8 +612,9 @@ class InterfaceFilter(django_filters.FilterSet):
|
|||||||
def filter_device(self, queryset, name, value):
|
def filter_device(self, queryset, name, value):
|
||||||
try:
|
try:
|
||||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||||
|
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||||
ordering = device.device_type.interface_ordering
|
ordering = device.device_type.interface_ordering
|
||||||
return queryset.filter(device=device).order_naturally(ordering)
|
return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
|
||||||
except Device.DoesNotExist:
|
except Device.DoesNotExist:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@@ -589,7 +646,11 @@ class DeviceBayFilter(DeviceComponentFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class InventoryItemFilter(DeviceComponentFilterSet):
|
class InventoryItemFilter(DeviceComponentFilterSet):
|
||||||
parent_id = NullableModelMultipleChoiceFilter(
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=InventoryItem.objects.all(),
|
queryset=InventoryItem.objects.all(),
|
||||||
label='Parent inventory item (ID)',
|
label='Parent inventory item (ID)',
|
||||||
)
|
)
|
||||||
@@ -607,7 +668,61 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = ['name', 'part_id', 'serial', 'discovered']
|
fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = (
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(part_id__icontains=value) |
|
||||||
|
Q(serial__iexact=value) |
|
||||||
|
Q(asset_tag__iexact=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualChassisFilter(django_filters.FilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__site',
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
label='Site (ID)',
|
||||||
|
)
|
||||||
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__site__slug',
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Site name (slug)',
|
||||||
|
)
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__tenant',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
label='Tenant (ID)',
|
||||||
|
)
|
||||||
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__tenant__slug',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Tenant (slug)',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['domain']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = (
|
||||||
|
Q(master__name__icontains=value) |
|
||||||
|
Q(domain__icontains=value)
|
||||||
|
)
|
||||||
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from netaddr import EUI, AddrFormatError
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from netaddr import EUI, AddrFormatError
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
32
netbox/dcim/migrations/0044_virtualization.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-08-31 14:15
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('virtualization', '0001_virtualization'),
|
||||||
|
('dcim', '0043_device_component_name_lengths'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='cluster',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='virtual_machine',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='interface',
|
||||||
|
name='device',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
netbox/dcim/migrations/0045_devicerole_vm_role.py
Normal file
20
netbox/dcim/migrations/0045_devicerole_vm_role.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-09-29 16:09
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0044_virtualization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicerole',
|
||||||
|
name='vm_role',
|
||||||
|
field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
netbox/dcim/migrations/0046_rack_lengthen_facility_id.py
Normal file
21
netbox/dcim/migrations/0046_rack_lengthen_facility_id.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-10-09 17:43
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import utilities.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0045_devicerole_vm_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='rack',
|
||||||
|
name='facility_id',
|
||||||
|
field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
netbox/dcim/migrations/0047_more_100ge_form_factors.py
Normal file
25
netbox/dcim/migrations/0047_more_100ge_form_factors.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-10-09 18:43
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0046_rack_lengthen_facility_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='interface',
|
||||||
|
name='form_factor',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='interfacetemplate',
|
||||||
|
name='form_factor',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
netbox/dcim/migrations/0048_rack_serial.py
Normal file
20
netbox/dcim/migrations/0048_rack_serial.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-10-09 18:50
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0047_more_100ge_form_factors'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rack',
|
||||||
|
name='serial',
|
||||||
|
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
netbox/dcim/migrations/0049_rackreservation_change_user.py
Normal file
22
netbox/dcim/migrations/0049_rackreservation_change_user.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-10-31 17:32
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0048_rack_serial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='rackreservation',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-11-10 20:10
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ipam', '0020_ipaddress_add_role_carp'),
|
||||||
|
('dcim', '0049_rackreservation_change_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='mode',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='tagged_vlans',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='untagged_vlan',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-11-15 18:56
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tenancy', '0003_unicode_literals'),
|
||||||
|
('dcim', '0050_interface_vlan_tagging'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rackreservation',
|
||||||
|
name='tenant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
|
||||||
|
),
|
||||||
|
]
|
||||||
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-11-27 17:27
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0051_rackreservation_tenant'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VirtualChassis',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('domain', models.CharField(blank=True, max_length=30)),
|
||||||
|
('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='virtual_chassis',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='vc_position',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='vc_priority',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='device',
|
||||||
|
unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
|
||||||
|
),
|
||||||
|
]
|
||||||
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2017-12-19 20:56
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0052_virtual_chassis'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platform',
|
||||||
|
name='manufacturer',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='platform',
|
||||||
|
name='napalm_driver',
|
||||||
|
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.6 on 2018-01-25 18:21
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import timezone_field.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0053_platform_manufacturer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='description',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='status',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='time_zone',
|
||||||
|
field=timezone_field.fields.TimeZoneField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-21 14:41
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0054_site_status_timezone_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='virtualchassis',
|
||||||
|
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='virtualchassis',
|
||||||
|
name='master',
|
||||||
|
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from itertools import count, groupby
|
from itertools import count, groupby
|
||||||
|
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -15,17 +13,19 @@ from django.db import models
|
|||||||
from django.db.models import Count, Q, ObjectDoesNotExist
|
from django.db.models import Count, Q, ObjectDoesNotExist
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
from circuits.models import Circuit
|
from circuits.models import Circuit
|
||||||
from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment
|
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
|
||||||
from extras.rpc import RPC_CLIENTS
|
from extras.rpc import RPC_CLIENTS
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.fields import ColorField, NullableCharField
|
from utilities.fields import ColorField, NullableCharField
|
||||||
from utilities.managers import NaturalOrderByManager
|
from utilities.managers import NaturalOrderByManager
|
||||||
from utilities.models import CreatedUpdatedModel
|
from utilities.models import CreatedUpdatedModel
|
||||||
from utilities.utils import csv_format
|
|
||||||
from .constants import *
|
from .constants import *
|
||||||
from .fields import ASNField, MACAddressField
|
from .fields import ASNField, MACAddressField
|
||||||
|
from .querysets import InterfaceQuerySet
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -43,6 +43,8 @@ class Region(MPTTModel):
|
|||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'parent']
|
||||||
|
|
||||||
class MPTTMeta:
|
class MPTTMeta:
|
||||||
order_insertion_by = ['name']
|
order_insertion_by = ['name']
|
||||||
|
|
||||||
@@ -52,6 +54,13 @@ class Region(MPTTModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
|
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.parent.name if self.parent else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Sites
|
# Sites
|
||||||
@@ -71,10 +80,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
|
||||||
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
|
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
|
||||||
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
|
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
|
||||||
facility = models.CharField(max_length=50, blank=True)
|
facility = models.CharField(max_length=50, blank=True)
|
||||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||||
|
time_zone = TimeZoneField(blank=True)
|
||||||
|
description = models.CharField(max_length=100, blank=True)
|
||||||
physical_address = models.CharField(max_length=200, blank=True)
|
physical_address = models.CharField(max_length=200, blank=True)
|
||||||
shipping_address = models.CharField(max_length=200, blank=True)
|
shipping_address = models.CharField(max_length=200, blank=True)
|
||||||
contact_name = models.CharField(max_length=50, blank=True)
|
contact_name = models.CharField(max_length=50, blank=True)
|
||||||
@@ -87,7 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
objects = SiteManager()
|
objects = SiteManager()
|
||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
|
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||||
|
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -100,17 +113,26 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return reverse('dcim:site', args=[self.slug])
|
return reverse('dcim:site', args=[self.slug])
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.name,
|
self.name,
|
||||||
self.slug,
|
self.slug,
|
||||||
|
self.get_status_display(),
|
||||||
self.region.name if self.region else None,
|
self.region.name if self.region else None,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.facility,
|
self.facility,
|
||||||
self.asn,
|
self.asn,
|
||||||
|
self.time_zone,
|
||||||
|
self.description,
|
||||||
|
self.physical_address,
|
||||||
|
self.shipping_address,
|
||||||
self.contact_name,
|
self.contact_name,
|
||||||
self.contact_phone,
|
self.contact_phone,
|
||||||
self.contact_email,
|
self.contact_email,
|
||||||
])
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_status_class(self):
|
||||||
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def count_prefixes(self):
|
def count_prefixes(self):
|
||||||
@@ -132,6 +154,11 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
def count_circuits(self):
|
def count_circuits(self):
|
||||||
return Circuit.objects.filter(terminations__site=self).count()
|
return Circuit.objects.filter(terminations__site=self).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count_vms(self):
|
||||||
|
from virtualization.models import VirtualMachine
|
||||||
|
return VirtualMachine.objects.filter(cluster__site=self).count()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Racks
|
# Racks
|
||||||
@@ -148,6 +175,8 @@ class RackGroup(models.Model):
|
|||||||
slug = models.SlugField()
|
slug = models.SlugField()
|
||||||
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
|
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
csv_headers = ['site', 'name', 'slug']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['site', 'name']
|
ordering = ['site', 'name']
|
||||||
unique_together = [
|
unique_together = [
|
||||||
@@ -161,6 +190,13 @@ class RackGroup(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.site,
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class RackRole(models.Model):
|
class RackRole(models.Model):
|
||||||
@@ -171,6 +207,8 @@ class RackRole(models.Model):
|
|||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
color = ColorField()
|
color = ColorField()
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'color']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
@@ -180,6 +218,13 @@ class RackRole(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RackManager(NaturalOrderByManager):
|
class RackManager(NaturalOrderByManager):
|
||||||
|
|
||||||
@@ -194,11 +239,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
|
facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID')
|
||||||
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
|
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
|
||||||
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
|
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
|
||||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
|
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
|
||||||
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
|
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
|
||||||
|
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
|
||||||
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
|
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
|
||||||
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
|
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
|
||||||
help_text='Rail-to-rail width')
|
help_text='Rail-to-rail width')
|
||||||
@@ -213,7 +259,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
objects = RackManager()
|
objects = RackManager()
|
||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
|
||||||
|
'desc_units', 'comments',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -231,8 +278,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Validate that Rack is tall enough to house the installed Devices
|
|
||||||
if self.pk:
|
if self.pk:
|
||||||
|
# Validate that Rack is tall enough to house the installed Devices
|
||||||
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
|
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
|
||||||
if top_device:
|
if top_device:
|
||||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
min_height = top_device.position + top_device.device_type.u_height - 1
|
||||||
@@ -242,6 +289,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
min_height
|
min_height
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
# Validate that Rack was assigned a group of its same site, if applicable
|
||||||
|
if self.group:
|
||||||
|
if self.group.site != self.site:
|
||||||
|
raise ValidationError({
|
||||||
|
'group': "Rack group must be from the same site, {}.".format(self.site)
|
||||||
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
@@ -257,7 +310,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
Device.objects.filter(rack=self).update(site_id=self.site.pk)
|
Device.objects.filter(rack=self).update(site_id=self.site.pk)
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.site.name,
|
self.site.name,
|
||||||
self.group.name if self.group else None,
|
self.group.name if self.group else None,
|
||||||
self.name,
|
self.name,
|
||||||
@@ -265,10 +318,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.role.name if self.role else None,
|
self.role.name if self.role else None,
|
||||||
self.get_type_display() if self.type else None,
|
self.get_type_display() if self.type else None,
|
||||||
|
self.serial,
|
||||||
self.width,
|
self.width,
|
||||||
self.u_height,
|
self.u_height,
|
||||||
self.desc_units,
|
self.desc_units,
|
||||||
])
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def units(self):
|
def units(self):
|
||||||
@@ -386,7 +441,8 @@ class RackReservation(models.Model):
|
|||||||
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
||||||
units = ArrayField(models.PositiveSmallIntegerField())
|
units = ArrayField(models.PositiveSmallIntegerField())
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
|
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||||
description = models.CharField(max_length=100)
|
description = models.CharField(max_length=100)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -443,6 +499,8 @@ class Manufacturer(models.Model):
|
|||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
@@ -452,6 +510,12 @@ class Manufacturer(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
|
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class DeviceType(models.Model, CustomFieldModel):
|
class DeviceType(models.Model, CustomFieldModel):
|
||||||
@@ -491,6 +555,11 @@ class DeviceType(models.Model, CustomFieldModel):
|
|||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
|
|
||||||
|
csv_headers = [
|
||||||
|
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
|
||||||
|
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['manufacturer', 'model']
|
ordering = ['manufacturer', 'model']
|
||||||
unique_together = [
|
unique_together = [
|
||||||
@@ -510,6 +579,22 @@ class DeviceType(models.Model, CustomFieldModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('dcim:devicetype', args=[self.pk])
|
return reverse('dcim:devicetype', args=[self.pk])
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.manufacturer.name,
|
||||||
|
self.model,
|
||||||
|
self.slug,
|
||||||
|
self.part_number,
|
||||||
|
self.u_height,
|
||||||
|
self.is_full_depth,
|
||||||
|
self.is_console_server,
|
||||||
|
self.is_pdu,
|
||||||
|
self.is_network_device,
|
||||||
|
self.get_subdevice_role_display() if self.subdevice_role else None,
|
||||||
|
self.get_interface_ordering_display(),
|
||||||
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
|
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
|
||||||
@@ -632,53 +717,6 @@ class PowerOutletTemplate(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class InterfaceQuerySet(models.QuerySet):
|
|
||||||
|
|
||||||
def order_naturally(self, method=IFACE_ORDERING_POSITION):
|
|
||||||
"""
|
|
||||||
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
|
|
||||||
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
|
|
||||||
|
|
||||||
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
|
|
||||||
slot, subslot, position, channel, and virtual circuit:
|
|
||||||
|
|
||||||
{type}{slot}/{subslot}/{position}:{channel}.{vc}
|
|
||||||
|
|
||||||
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
|
|
||||||
be parsed as follows:
|
|
||||||
|
|
||||||
name = 'GigabitEthernet'
|
|
||||||
slot = None
|
|
||||||
subslot = 0
|
|
||||||
position = 1
|
|
||||||
channel = None
|
|
||||||
vc = 0
|
|
||||||
|
|
||||||
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
|
|
||||||
the prescribed fields.
|
|
||||||
"""
|
|
||||||
sql_col = '{}.name'.format(self.model._meta.db_table)
|
|
||||||
ordering = {
|
|
||||||
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
|
|
||||||
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
|
|
||||||
}[method]
|
|
||||||
return self.extra(select={
|
|
||||||
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
|
|
||||||
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
|
||||||
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
|
||||||
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
|
||||||
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
|
|
||||||
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
|
|
||||||
}).order_by(*ordering)
|
|
||||||
|
|
||||||
def connectable(self):
|
|
||||||
"""
|
|
||||||
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
|
|
||||||
wireless).
|
|
||||||
"""
|
|
||||||
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
|
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class InterfaceTemplate(models.Model):
|
class InterfaceTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -723,11 +761,19 @@ class DeviceBayTemplate(models.Model):
|
|||||||
class DeviceRole(models.Model):
|
class DeviceRole(models.Model):
|
||||||
"""
|
"""
|
||||||
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
|
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
|
||||||
color to be used when displaying rack elevations.
|
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
|
||||||
|
virtual machines as well.
|
||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
color = ColorField()
|
color = ColorField()
|
||||||
|
vm_role = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="VM Role",
|
||||||
|
help_text="Virtual machines may be assigned to this role"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'color', 'vm_role']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
@@ -738,20 +784,45 @@ class DeviceRole(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
|
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.color,
|
||||||
|
self.vm_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Platform(models.Model):
|
class Platform(models.Model):
|
||||||
"""
|
"""
|
||||||
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
|
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
|
||||||
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
||||||
specifying an remote procedure call (RPC) client.
|
specifying a NAPALM driver.
|
||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
|
manufacturer = models.ForeignKey(
|
||||||
help_text="The name of the NAPALM driver to use when interacting with devices.")
|
to='Manufacturer',
|
||||||
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
|
related_name='platforms',
|
||||||
verbose_name='Legacy RPC client')
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Optionally limit this platform to devices of a certain manufacturer"
|
||||||
|
)
|
||||||
|
napalm_driver = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='NAPALM driver',
|
||||||
|
help_text="The name of the NAPALM driver to use when interacting with devices"
|
||||||
|
)
|
||||||
|
rpc_client = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=RPC_CLIENT_CHOICES,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Legacy RPC client"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
@@ -762,6 +833,14 @@ class Platform(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
|
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.manufacturer.name if self.manufacturer else None,
|
||||||
|
self.napalm_driver,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeviceManager(NaturalOrderByManager):
|
class DeviceManager(NaturalOrderByManager):
|
||||||
|
|
||||||
@@ -799,7 +878,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
help_text='The lowest-numbered unit occupied by the device'
|
help_text='The lowest-numbered unit occupied by the device'
|
||||||
)
|
)
|
||||||
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||||
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
|
||||||
primary_ip4 = models.OneToOneField(
|
primary_ip4 = models.OneToOneField(
|
||||||
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||||
verbose_name='Primary IPv4'
|
verbose_name='Primary IPv4'
|
||||||
@@ -808,6 +887,30 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
|
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||||
verbose_name='Primary IPv6'
|
verbose_name='Primary IPv6'
|
||||||
)
|
)
|
||||||
|
cluster = models.ForeignKey(
|
||||||
|
to='virtualization.Cluster',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='devices',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
virtual_chassis = models.ForeignKey(
|
||||||
|
to='VirtualChassis',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='members',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
vc_position = models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[MaxValueValidator(255)]
|
||||||
|
)
|
||||||
|
vc_priority = models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[MaxValueValidator(255)]
|
||||||
|
)
|
||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
images = GenericRelation(ImageAttachment)
|
images = GenericRelation(ImageAttachment)
|
||||||
@@ -816,12 +919,15 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||||
'site', 'rack_group', 'rack_name', 'position', 'face',
|
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
unique_together = ['rack', 'position', 'face']
|
unique_together = [
|
||||||
|
['rack', 'position', 'face'],
|
||||||
|
['virtual_chassis', 'vc_position'],
|
||||||
|
]
|
||||||
permissions = (
|
permissions = (
|
||||||
('napalm_read', 'Read-only access to devices via NAPALM'),
|
('napalm_read', 'Read-only access to devices via NAPALM'),
|
||||||
('napalm_write', 'Read/write access to devices via NAPALM'),
|
('napalm_write', 'Read/write access to devices via NAPALM'),
|
||||||
@@ -857,6 +963,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
'face': "Must specify rack face when defining rack position.",
|
'face': "Must specify rack face when defining rack position.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Prevent 0U devices from being assigned to a specific position
|
||||||
|
if self.position and self.device_type.u_height == 0:
|
||||||
|
raise ValidationError({
|
||||||
|
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
|
||||||
|
})
|
||||||
|
|
||||||
if self.rack:
|
if self.rack:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -890,16 +1002,47 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
except DeviceType.DoesNotExist:
|
except DeviceType.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Validate primary IPv4 address
|
# Validate primary IP addresses
|
||||||
if self.primary_ip4 and (self.primary_ip4.interface is None or self.primary_ip4.interface.device != self):
|
vc_interfaces = self.vc_interfaces.all()
|
||||||
|
if self.primary_ip4:
|
||||||
|
if self.primary_ip4.interface in vc_interfaces:
|
||||||
|
pass
|
||||||
|
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
|
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
|
||||||
|
self.primary_ip4),
|
||||||
|
})
|
||||||
|
if self.primary_ip6:
|
||||||
|
if self.primary_ip6.interface in vc_interfaces:
|
||||||
|
pass
|
||||||
|
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValidationError({
|
||||||
|
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
|
||||||
|
self.primary_ip6),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Validate primary IPv6 address
|
# Validate manufacturer/platform
|
||||||
if self.primary_ip6 and (self.primary_ip6.interface is None or self.primary_ip6.interface.device != self):
|
if self.device_type and self.platform:
|
||||||
|
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
|
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||||
|
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||||
|
})
|
||||||
|
|
||||||
|
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||||
|
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||||
|
raise ValidationError({
|
||||||
|
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Validate virtual chassis assignment
|
||||||
|
if self.virtual_chassis and self.vc_position is None:
|
||||||
|
raise ValidationError({
|
||||||
|
'vc_position': "A device assigned to a virtual chassis must have its position defined."
|
||||||
})
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -939,7 +1082,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
|
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.name or '',
|
self.name or '',
|
||||||
self.device_role.name,
|
self.device_role.name,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
@@ -954,12 +1097,15 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.rack.name if self.rack else None,
|
self.rack.name if self.rack else None,
|
||||||
self.position,
|
self.position,
|
||||||
self.get_face_display(),
|
self.get_face_display(),
|
||||||
])
|
self.comments,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
if self.name:
|
if self.name:
|
||||||
return self.name
|
return self.name
|
||||||
|
elif self.virtual_chassis and self.virtual_chassis.master.name:
|
||||||
|
return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
|
||||||
elif hasattr(self, 'device_type'):
|
elif hasattr(self, 'device_type'):
|
||||||
return "{}".format(self.device_type)
|
return "{}".format(self.device_type)
|
||||||
return ""
|
return ""
|
||||||
@@ -984,6 +1130,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_vc_master(self):
|
||||||
|
"""
|
||||||
|
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||||
|
"""
|
||||||
|
return self.virtual_chassis.master if self.virtual_chassis else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vc_interfaces(self):
|
||||||
|
"""
|
||||||
|
Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
|
||||||
|
Device belonging to the same VirtualChassis.
|
||||||
|
"""
|
||||||
|
filter = Q(device=self)
|
||||||
|
if self.virtual_chassis and self.virtual_chassis.master == self:
|
||||||
|
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
|
||||||
|
return Interface.objects.filter(filter)
|
||||||
|
|
||||||
def get_children(self):
|
def get_children(self):
|
||||||
"""
|
"""
|
||||||
Return the set of child Devices installed in DeviceBays within this Device.
|
Return the set of child Devices installed in DeviceBays within this Device.
|
||||||
@@ -991,7 +1154,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return Device.objects.filter(parent_bay__device=self.pk)
|
return Device.objects.filter(parent_bay__device=self.pk)
|
||||||
|
|
||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return DEVICE_STATUS_CLASSES[self.status]
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
def get_rpc_client(self):
|
def get_rpc_client(self):
|
||||||
"""
|
"""
|
||||||
@@ -1026,15 +1189,17 @@ class ConsolePort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
# Used for connections export
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.cs_port.device.identifier if self.cs_port else None,
|
self.cs_port.device.identifier if self.cs_port else None,
|
||||||
self.cs_port.name if self.cs_port else None,
|
self.cs_port.name if self.cs_port else None,
|
||||||
self.device.identifier,
|
self.device.identifier,
|
||||||
self.name,
|
self.name,
|
||||||
self.get_connection_status_display(),
|
self.get_connection_status_display(),
|
||||||
])
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -1044,16 +1209,11 @@ class ConsolePort(models.Model):
|
|||||||
class ConsoleServerPortManager(models.Manager):
|
class ConsoleServerPortManager(models.Manager):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
# Pad any trailing digits to effect natural sorting
|
||||||
Include the trailing numeric portion of each port name to allow for proper ordering.
|
|
||||||
For example:
|
|
||||||
Port 1, Port 2, Port 3 ... Port 9, Port 10, Port 11 ...
|
|
||||||
Instead of:
|
|
||||||
Port 1, Port 10, Port 11 ... Port 19, Port 2, Port 20 ...
|
|
||||||
"""
|
|
||||||
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
|
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
|
||||||
'name_as_integer': "CAST(substring(dcim_consoleserverport.name FROM '[0-9]+$') AS INTEGER)",
|
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
|
||||||
}).order_by('device', 'name_as_integer')
|
r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
|
||||||
|
}).order_by('device', 'name_padded')
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@@ -1072,6 +1232,20 @@ class ConsoleServerPort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Check that the parent device's DeviceType is a console server
|
||||||
|
if self.device is None:
|
||||||
|
raise ValidationError("Console server ports must be assigned to devices.")
|
||||||
|
device_type = self.device.device_type
|
||||||
|
if not device_type.is_console_server:
|
||||||
|
raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
|
||||||
|
device_type.manufacturer, device_type
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Power ports
|
# Power ports
|
||||||
@@ -1097,15 +1271,17 @@ class PowerPort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
# Used for connections export
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.power_outlet.device.identifier if self.power_outlet else None,
|
self.power_outlet.device.identifier if self.power_outlet else None,
|
||||||
self.power_outlet.name if self.power_outlet else None,
|
self.power_outlet.name if self.power_outlet else None,
|
||||||
self.device.identifier,
|
self.device.identifier,
|
||||||
self.name,
|
self.name,
|
||||||
self.get_connection_status_display(),
|
self.get_connection_status_display(),
|
||||||
])
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -1115,9 +1291,10 @@ class PowerPort(models.Model):
|
|||||||
class PowerOutletManager(models.Manager):
|
class PowerOutletManager(models.Manager):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
# Pad any trailing digits to effect natural sorting
|
||||||
return super(PowerOutletManager, self).get_queryset().extra(select={
|
return super(PowerOutletManager, self).get_queryset().extra(select={
|
||||||
'name_padded': "CONCAT(SUBSTRING(dcim_poweroutlet.name FROM '^[^0-9]+'), "
|
'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
|
||||||
"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '[0-9\/]+$'), 8, '0'))",
|
r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
|
||||||
}).order_by('device', 'name_padded')
|
}).order_by('device', 'name_padded')
|
||||||
|
|
||||||
|
|
||||||
@@ -1137,6 +1314,20 @@ class PowerOutlet(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Check that the parent device's DeviceType is a PDU
|
||||||
|
if self.device is None:
|
||||||
|
raise ValidationError("Power outlets must be assigned to devices.")
|
||||||
|
device_type = self.device.device_type
|
||||||
|
if not device_type.is_pdu:
|
||||||
|
raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
|
||||||
|
device_type.manufacturer, device_type
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Interfaces
|
# Interfaces
|
||||||
@@ -1145,13 +1336,26 @@ class PowerOutlet(models.Model):
|
|||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Interface(models.Model):
|
class Interface(models.Model):
|
||||||
"""
|
"""
|
||||||
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
|
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
||||||
of an InterfaceConnection.
|
Interface via the creation of an InterfaceConnection.
|
||||||
"""
|
"""
|
||||||
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
|
device = models.ForeignKey(
|
||||||
|
to='Device',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='interfaces',
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
virtual_machine = models.ForeignKey(
|
||||||
|
to='virtualization.VirtualMachine',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='interfaces',
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
lag = models.ForeignKey(
|
lag = models.ForeignKey(
|
||||||
'self',
|
to='self',
|
||||||
models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name='member_interfaces',
|
related_name='member_interfaces',
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -1168,6 +1372,24 @@ class Interface(models.Model):
|
|||||||
help_text="This interface is used only for out-of-band management"
|
help_text="This interface is used only for out-of-band management"
|
||||||
)
|
)
|
||||||
description = models.CharField(max_length=100, blank=True)
|
description = models.CharField(max_length=100, blank=True)
|
||||||
|
mode = models.PositiveSmallIntegerField(
|
||||||
|
choices=IFACE_MODE_CHOICES,
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
untagged_vlan = models.ForeignKey(
|
||||||
|
to='ipam.VLAN',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Untagged VLAN',
|
||||||
|
related_name='interfaces_as_untagged'
|
||||||
|
)
|
||||||
|
tagged_vlans = models.ManyToManyField(
|
||||||
|
to='ipam.VLAN',
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Tagged VLANs',
|
||||||
|
related_name='interfaces_as_tagged'
|
||||||
|
)
|
||||||
|
|
||||||
objects = InterfaceQuerySet.as_manager()
|
objects = InterfaceQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -1178,8 +1400,31 @@ class Interface(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.parent.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
|
# Check that the parent device's DeviceType is a network device
|
||||||
|
if self.device is not None:
|
||||||
|
device_type = self.device.device_type
|
||||||
|
if not device_type.is_network_device:
|
||||||
|
raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
|
||||||
|
device_type.manufacturer, device_type
|
||||||
|
))
|
||||||
|
|
||||||
|
# An Interface must belong to a Device *or* to a VirtualMachine
|
||||||
|
if self.device and self.virtual_machine:
|
||||||
|
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
|
||||||
|
if not self.device and not self.virtual_machine:
|
||||||
|
raise ValidationError("An interface must belong to either a device or a virtual machine.")
|
||||||
|
|
||||||
|
# VM interfaces must be virtual
|
||||||
|
if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL:
|
||||||
|
raise ValidationError({
|
||||||
|
'form_factor': "Virtual machines can only have virtual interfaces."
|
||||||
|
})
|
||||||
|
|
||||||
# Virtual interfaces cannot be connected
|
# Virtual interfaces cannot be connected
|
||||||
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
|
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
@@ -1187,8 +1432,8 @@ class Interface(models.Model):
|
|||||||
"Disconnect the interface or choose a suitable form factor."
|
"Disconnect the interface or choose a suitable form factor."
|
||||||
})
|
})
|
||||||
|
|
||||||
# An interface's LAG must belong to the same device
|
# An interface's LAG must belong to the same device (or VC master)
|
||||||
if self.lag and self.lag.device != self.device:
|
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||||
self.lag.name, self.lag.device.name
|
self.lag.name, self.lag.device.name
|
||||||
@@ -1209,6 +1454,29 @@ class Interface(models.Model):
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Validate untagged VLAN
|
||||||
|
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||||
|
raise ValidationError({
|
||||||
|
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
|
||||||
|
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||||
|
})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||||
|
if self.mode is None:
|
||||||
|
self.untagged_vlan = None
|
||||||
|
|
||||||
|
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||||
|
if self.pk and self.mode is not IFACE_MODE_TAGGED:
|
||||||
|
self.tagged_vlans.clear()
|
||||||
|
|
||||||
|
return super(Interface, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent(self):
|
||||||
|
return self.device or self.virtual_machine
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_virtual(self):
|
def is_virtual(self):
|
||||||
return self.form_factor in VIRTUAL_IFACE_TYPES
|
return self.form_factor in VIRTUAL_IFACE_TYPES
|
||||||
@@ -1274,18 +1542,29 @@ class InterfaceConnection(models.Model):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'interface_b': "Cannot connect an interface to itself."
|
'interface_b': "Cannot connect an interface to itself."
|
||||||
})
|
})
|
||||||
|
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
||||||
|
raise ValidationError({
|
||||||
|
'interface_a': '{} is not a connectable interface type.'.format(
|
||||||
|
self.interface_a.get_form_factor_display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
||||||
|
raise ValidationError({
|
||||||
|
'interface_b': '{} is not a connectable interface type.'.format(
|
||||||
|
self.interface_b.get_form_factor_display()
|
||||||
|
)
|
||||||
|
})
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Used for connections export
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.interface_a.device.identifier,
|
self.interface_a.device.identifier,
|
||||||
self.interface_a.name,
|
self.interface_a.name,
|
||||||
self.interface_b.device.identifier,
|
self.interface_b.device.identifier,
|
||||||
self.interface_b.name,
|
self.interface_b.name,
|
||||||
self.get_connection_status_display(),
|
self.get_connection_status_display(),
|
||||||
])
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -1309,6 +1588,9 @@ class DeviceBay(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{} - {}'.format(self.device.name, self.name)
|
return '{} - {}'.format(self.device.name, self.name)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Validate that the parent Device can have DeviceBays
|
# Validate that the parent Device can have DeviceBays
|
||||||
@@ -1347,9 +1629,67 @@ class InventoryItem(models.Model):
|
|||||||
discovered = models.BooleanField(default=False, verbose_name='Discovered')
|
discovered = models.BooleanField(default=False, verbose_name='Discovered')
|
||||||
description = models.CharField(max_length=100, blank=True)
|
description = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
csv_headers = [
|
||||||
|
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['device__id', 'parent__id', 'name']
|
ordering = ['device__id', 'parent__id', 'name']
|
||||||
unique_together = ['device', 'parent', 'name']
|
unique_together = ['device', 'parent', 'name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.device.name or '{' + self.device.pk + '}',
|
||||||
|
self.name,
|
||||||
|
self.manufacturer.name if self.manufacturer else None,
|
||||||
|
self.part_id,
|
||||||
|
self.serial,
|
||||||
|
self.asset_tag,
|
||||||
|
self.discovered,
|
||||||
|
self.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Virtual chassis
|
||||||
|
#
|
||||||
|
|
||||||
|
@python_2_unicode_compatible
|
||||||
|
class VirtualChassis(models.Model):
|
||||||
|
"""
|
||||||
|
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||||
|
"""
|
||||||
|
master = models.OneToOneField(
|
||||||
|
to='Device',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='vc_master_for'
|
||||||
|
)
|
||||||
|
domain = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['master']
|
||||||
|
verbose_name_plural = 'virtual chassis'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.master.get_absolute_url()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||||
|
# VirtualChassis.)
|
||||||
|
if self.pk and self.master not in self.members.all():
|
||||||
|
raise ValidationError({
|
||||||
|
'master': "The selected master is not assigned to this virtual chassis."
|
||||||
|
})
|
||||||
|
|||||||
72
netbox/dcim/querysets.py
Normal file
72
netbox/dcim/querysets.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models.expressions import RawSQL
|
||||||
|
|
||||||
|
from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceQuerySet(QuerySet):
|
||||||
|
|
||||||
|
def order_naturally(self, method=IFACE_ORDERING_POSITION):
|
||||||
|
"""
|
||||||
|
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
|
||||||
|
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
|
||||||
|
|
||||||
|
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
|
||||||
|
slot, subslot, position, channel, and virtual circuit:
|
||||||
|
|
||||||
|
{type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
|
||||||
|
|
||||||
|
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
|
||||||
|
would be parsed as follows:
|
||||||
|
|
||||||
|
name = 'GigabitEthernet'
|
||||||
|
slot = 1
|
||||||
|
subslot = 2
|
||||||
|
position = 3
|
||||||
|
subposition = 0
|
||||||
|
channel = None
|
||||||
|
vc = 0
|
||||||
|
|
||||||
|
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
|
||||||
|
the prescribed fields.
|
||||||
|
"""
|
||||||
|
sql_col = '{}.name'.format(self.model._meta.db_table)
|
||||||
|
ordering = {
|
||||||
|
IFACE_ORDERING_POSITION: (
|
||||||
|
'_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name',
|
||||||
|
),
|
||||||
|
IFACE_ORDERING_NAME: (
|
||||||
|
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
|
||||||
|
),
|
||||||
|
}[method]
|
||||||
|
|
||||||
|
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
|
||||||
|
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)"
|
||||||
|
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)"
|
||||||
|
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)"
|
||||||
|
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)"
|
||||||
|
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)"
|
||||||
|
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
|
||||||
|
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)"
|
||||||
|
|
||||||
|
fields = {
|
||||||
|
'_type': RawSQL(TYPE_RE.format(sql_col), []),
|
||||||
|
'_id': RawSQL(ID_RE.format(sql_col), []),
|
||||||
|
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
|
||||||
|
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
|
||||||
|
'_position': RawSQL(POSITION_RE.format(sql_col), []),
|
||||||
|
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
|
||||||
|
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
|
||||||
|
'_vc': RawSQL(VC_RE.format(sql_col), []),
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.annotate(**fields).order_by(*ordering)
|
||||||
|
|
||||||
|
def connectable(self):
|
||||||
|
"""
|
||||||
|
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
|
||||||
|
wireless).
|
||||||
|
"""
|
||||||
|
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
|
||||||
28
netbox/dcim/signals.py
Normal file
28
netbox/dcim/signals.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .models import Device, VirtualChassis
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=VirtualChassis)
|
||||||
|
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
When a VirtualChassis is created, automatically assign its master device to the VC.
|
||||||
|
"""
|
||||||
|
# Default to 1 but don't overwrite an existing position (see #2087)
|
||||||
|
if instance.master.vc_position is not None:
|
||||||
|
vc_position = instance.master.vc_position
|
||||||
|
else:
|
||||||
|
vc_position = 1
|
||||||
|
if created:
|
||||||
|
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=VirtualChassis)
|
||||||
|
def clear_virtualchassis_members(instance, **kwargs):
|
||||||
|
"""
|
||||||
|
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
|
||||||
|
"""
|
||||||
|
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)
|
||||||
@@ -3,14 +3,15 @@ from __future__ import unicode_literals
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
|
|
||||||
|
from tenancy.tables import COL_TENANT
|
||||||
from utilities.tables import BaseTable, ToggleColumn
|
from utilities.tables import BaseTable, ToggleColumn
|
||||||
from .models import (
|
from .models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
|
||||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
|
Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
|
RackReservation, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
REGION_LINK = """
|
REGION_LINK = """
|
||||||
{% if record.get_children %}
|
{% if record.get_children %}
|
||||||
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
|
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
|
||||||
@@ -46,8 +47,13 @@ REGION_ACTIONS = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
RACKGROUP_ACTIONS = """
|
RACKGROUP_ACTIONS = """
|
||||||
|
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
</a>
|
||||||
{% if perms.dcim.change_rackgroup %}
|
{% if perms.dcim.change_rackgroup %}
|
||||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
|
||||||
|
<i class="glyphicon glyphicon-pencil"></i>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -65,6 +71,10 @@ RACK_ROLE = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
RACK_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
RACKRESERVATION_ACTIONS = """
|
RACKRESERVATION_ACTIONS = """
|
||||||
{% if perms.dcim.change_rackreservation %}
|
{% if perms.dcim.change_rackreservation %}
|
||||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||||
@@ -83,6 +93,22 @@ MANUFACTURER_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
DEVICEROLE_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEVICEROLE_VM_COUNT = """
|
||||||
|
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLATFORM_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLATFORM_VM_COUNT = """
|
||||||
|
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
PLATFORM_ACTIONS = """
|
PLATFORM_ACTIONS = """
|
||||||
{% if perms.dcim.change_platform %}
|
{% if perms.dcim.change_platform %}
|
||||||
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||||
@@ -93,7 +119,7 @@ DEVICE_ROLE = """
|
|||||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEVICE_STATUS = """
|
STATUS_LABEL = """
|
||||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -107,11 +133,21 @@ SUBDEVICE_ROLE_TEMPLATE = """
|
|||||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
DEVICETYPE_INSTANCES_TEMPLATE = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
UTILIZATION_GRAPH = """
|
UTILIZATION_GRAPH = """
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% utilization_graph value %}
|
{% utilization_graph value %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
VIRTUALCHASSIS_ACTIONS = """
|
||||||
|
{% if perms.dcim.change_virtualchassis %}
|
||||||
|
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Regions
|
# Regions
|
||||||
@@ -140,26 +176,13 @@ class RegionTable(BaseTable):
|
|||||||
class SiteTable(BaseTable):
|
class SiteTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Site
|
model = Site
|
||||||
fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
|
fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
|
||||||
|
|
||||||
|
|
||||||
class SiteDetailTable(SiteTable):
|
|
||||||
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
|
|
||||||
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
|
|
||||||
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
|
|
||||||
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
|
|
||||||
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
|
|
||||||
|
|
||||||
class Meta(SiteTable.Meta):
|
|
||||||
fields = (
|
|
||||||
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
|
|
||||||
'vlan_count', 'circuit_count',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -168,12 +191,21 @@ class SiteDetailTable(SiteTable):
|
|||||||
|
|
||||||
class RackGroupTable(BaseTable):
|
class RackGroupTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
name = tables.LinkColumn()
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
site = tables.LinkColumn(
|
||||||
rack_count = tables.Column(verbose_name='Racks')
|
viewname='dcim:site',
|
||||||
slug = tables.Column(verbose_name='Slug')
|
args=[Accessor('site.slug')],
|
||||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
verbose_name='Site'
|
||||||
verbose_name='')
|
)
|
||||||
|
rack_count = tables.Column(
|
||||||
|
verbose_name='Racks'
|
||||||
|
)
|
||||||
|
slug = tables.Column()
|
||||||
|
actions = tables.TemplateColumn(
|
||||||
|
template_code=RACKGROUP_ACTIONS,
|
||||||
|
attrs={'td': {'class': 'text-right'}},
|
||||||
|
verbose_name=''
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = RackGroup
|
model = RackGroup
|
||||||
@@ -207,7 +239,7 @@ class RackTable(BaseTable):
|
|||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
role = tables.TemplateColumn(RACK_ROLE)
|
role = tables.TemplateColumn(RACK_ROLE)
|
||||||
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
||||||
|
|
||||||
@@ -217,12 +249,16 @@ class RackTable(BaseTable):
|
|||||||
|
|
||||||
|
|
||||||
class RackDetailTable(RackTable):
|
class RackDetailTable(RackTable):
|
||||||
devices = tables.Column(accessor=Accessor('device_count'))
|
device_count = tables.TemplateColumn(
|
||||||
|
template_code=RACK_DEVICE_COUNT,
|
||||||
|
verbose_name='Devices'
|
||||||
|
)
|
||||||
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||||
|
|
||||||
class Meta(RackTable.Meta):
|
class Meta(RackTable.Meta):
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
|
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||||
|
'get_utilization',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,7 +267,7 @@ class RackImportTable(BaseTable):
|
|||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||||
facility_id = tables.Column(verbose_name='Facility ID')
|
facility_id = tables.Column(verbose_name='Facility ID')
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
u_height = tables.Column(verbose_name='Height (U)')
|
u_height = tables.Column(verbose_name='Height (U)')
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
@@ -245,6 +281,7 @@ class RackImportTable(BaseTable):
|
|||||||
|
|
||||||
class RackReservationTable(BaseTable):
|
class RackReservationTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
|
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@@ -253,7 +290,7 @@ class RackReservationTable(BaseTable):
|
|||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -264,13 +301,14 @@ class ManufacturerTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
name = tables.LinkColumn(verbose_name='Name')
|
||||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||||
|
platform_count = tables.Column(verbose_name='Platforms')
|
||||||
slug = tables.Column(verbose_name='Slug')
|
slug = tables.Column(verbose_name='Slug')
|
||||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||||
verbose_name='')
|
verbose_name='')
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
|
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -279,13 +317,23 @@ class ManufacturerTable(BaseTable):
|
|||||||
|
|
||||||
class DeviceTypeTable(BaseTable):
|
class DeviceTypeTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
model = tables.LinkColumn(
|
||||||
|
viewname='dcim:devicetype',
|
||||||
|
args=[Accessor('pk')],
|
||||||
|
verbose_name='Device Type'
|
||||||
|
)
|
||||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
subdevice_role = tables.TemplateColumn(
|
||||||
instance_count = tables.Column(verbose_name='Instances')
|
template_code=SUBDEVICE_ROLE_TEMPLATE,
|
||||||
|
verbose_name='Subdevice Role'
|
||||||
|
)
|
||||||
|
instance_count = tables.TemplateColumn(
|
||||||
|
template_code=DEVICETYPE_INSTANCES_TEMPLATE,
|
||||||
|
verbose_name='Instances'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
@@ -361,15 +409,29 @@ class DeviceBayTemplateTable(BaseTable):
|
|||||||
class DeviceRoleTable(BaseTable):
|
class DeviceRoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
name = tables.LinkColumn(verbose_name='Name')
|
||||||
device_count = tables.Column(verbose_name='Devices')
|
device_count = tables.TemplateColumn(
|
||||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
template_code=DEVICEROLE_DEVICE_COUNT,
|
||||||
|
accessor=Accessor('devices.count'),
|
||||||
|
orderable=False,
|
||||||
|
verbose_name='Devices'
|
||||||
|
)
|
||||||
|
vm_count = tables.TemplateColumn(
|
||||||
|
template_code=DEVICEROLE_VM_COUNT,
|
||||||
|
accessor=Accessor('virtual_machines.count'),
|
||||||
|
orderable=False,
|
||||||
|
verbose_name='VMs'
|
||||||
|
)
|
||||||
|
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
|
||||||
slug = tables.Column(verbose_name='Slug')
|
slug = tables.Column(verbose_name='Slug')
|
||||||
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
actions = tables.TemplateColumn(
|
||||||
verbose_name='')
|
template_code=DEVICEROLE_ACTIONS,
|
||||||
|
attrs={'td': {'class': 'text-right'}},
|
||||||
|
verbose_name=''
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = DeviceRole
|
model = DeviceRole
|
||||||
fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
|
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'slug', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -378,16 +440,27 @@ class DeviceRoleTable(BaseTable):
|
|||||||
|
|
||||||
class PlatformTable(BaseTable):
|
class PlatformTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
device_count = tables.TemplateColumn(
|
||||||
device_count = tables.Column(verbose_name='Devices')
|
template_code=PLATFORM_DEVICE_COUNT,
|
||||||
slug = tables.Column(verbose_name='Slug')
|
accessor=Accessor('devices.count'),
|
||||||
rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client')
|
orderable=False,
|
||||||
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
verbose_name='Devices'
|
||||||
verbose_name='')
|
)
|
||||||
|
vm_count = tables.TemplateColumn(
|
||||||
|
template_code=PLATFORM_VM_COUNT,
|
||||||
|
accessor=Accessor('virtual_machines.count'),
|
||||||
|
orderable=False,
|
||||||
|
verbose_name='VMs'
|
||||||
|
)
|
||||||
|
actions = tables.TemplateColumn(
|
||||||
|
template_code=PLATFORM_ACTIONS,
|
||||||
|
attrs={'td': {'class': 'text-right'}},
|
||||||
|
verbose_name=''
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
|
fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -397,8 +470,8 @@ class PlatformTable(BaseTable):
|
|||||||
class DeviceTable(BaseTable):
|
class DeviceTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||||
@@ -424,8 +497,8 @@ class DeviceDetailTable(DeviceTable):
|
|||||||
|
|
||||||
class DeviceImportTable(BaseTable):
|
class DeviceImportTable(BaseTable):
|
||||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||||
position = tables.Column(verbose_name='Position')
|
position = tables.Column(verbose_name='Position')
|
||||||
@@ -521,5 +594,38 @@ class InterfaceConnectionTable(BaseTable):
|
|||||||
interface_b = tables.Column(verbose_name='Interface B')
|
interface_b = tables.Column(verbose_name='Interface B')
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Interface
|
model = InterfaceConnection
|
||||||
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
|
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# InventoryItems
|
||||||
|
#
|
||||||
|
|
||||||
|
class InventoryItemTable(BaseTable):
|
||||||
|
pk = ToggleColumn()
|
||||||
|
device = tables.LinkColumn('dcim:device_inventory', args=[Accessor('device.pk')])
|
||||||
|
manufacturer = tables.Column(accessor=Accessor('manufacturer.name'), verbose_name='Manufacturer')
|
||||||
|
|
||||||
|
class Meta(BaseTable.Meta):
|
||||||
|
model = InventoryItem
|
||||||
|
fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Virtual chassis
|
||||||
|
#
|
||||||
|
|
||||||
|
class VirtualChassisTable(BaseTable):
|
||||||
|
pk = ToggleColumn()
|
||||||
|
master = tables.LinkColumn()
|
||||||
|
member_count = tables.Column(verbose_name='Members')
|
||||||
|
actions = tables.TemplateColumn(
|
||||||
|
template_code=VIRTUALCHASSIS_ACTIONS,
|
||||||
|
attrs={'td': {'class': 'text-right'}},
|
||||||
|
verbose_name=''
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(BaseTable.Meta):
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ('pk', 'master', 'domain', 'member_count', 'actions')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_FRONT,
|
'face': RACK_FACE_FRONT,
|
||||||
'position': 41,
|
'position': 41,
|
||||||
'platform': get_id(Platform, 'juniper-junos'),
|
'platform': get_id(Platform, 'juniper-junos'),
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
@@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_FRONT,
|
'face': RACK_FACE_FRONT,
|
||||||
'position': 1,
|
'position': 1,
|
||||||
'platform': get_id(Platform, 'juniper-junos'),
|
'platform': get_id(Platform, 'juniper-junos'),
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertFalse(test.is_valid())
|
self.assertFalse(test.is_valid())
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': None,
|
'face': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
'platform': None,
|
'platform': None,
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid())
|
self.assertTrue(test.is_valid())
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
@@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_REAR,
|
'face': RACK_FACE_REAR,
|
||||||
'position': None,
|
'position': None,
|
||||||
'platform': None,
|
'platform': None,
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid())
|
self.assertTrue(test.is_valid())
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
|
|||||||
@@ -9,14 +9,29 @@ class RackTestCase(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
self.site = Site.objects.create(
|
self.site1 = Site.objects.create(
|
||||||
name='TestSite1',
|
name='TestSite1',
|
||||||
slug='my-test-site'
|
slug='test-site-1'
|
||||||
|
)
|
||||||
|
self.site2 = Site.objects.create(
|
||||||
|
name='TestSite2',
|
||||||
|
slug='test-site-2'
|
||||||
|
)
|
||||||
|
self.group1 = RackGroup.objects.create(
|
||||||
|
name='TestGroup1',
|
||||||
|
slug='test-group-1',
|
||||||
|
site=self.site1
|
||||||
|
)
|
||||||
|
self.group2 = RackGroup.objects.create(
|
||||||
|
name='TestGroup2',
|
||||||
|
slug='test-group-2',
|
||||||
|
site=self.site2
|
||||||
)
|
)
|
||||||
self.rack = Rack.objects.create(
|
self.rack = Rack.objects.create(
|
||||||
name='TestRack1',
|
name='TestRack1',
|
||||||
facility_id='A101',
|
facility_id='A101',
|
||||||
site=self.site,
|
site=self.site1,
|
||||||
|
group=self.group1,
|
||||||
u_height=42
|
u_height=42
|
||||||
)
|
)
|
||||||
self.manufacturer = Manufacturer.objects.create(
|
self.manufacturer = Manufacturer.objects.create(
|
||||||
@@ -57,13 +72,51 @@ class RackTestCase(TestCase):
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_rack_device_outside_height(self):
|
||||||
|
|
||||||
|
rack1 = Rack(
|
||||||
|
name='TestRack2',
|
||||||
|
facility_id='A102',
|
||||||
|
site=self.site1,
|
||||||
|
u_height=42
|
||||||
|
)
|
||||||
|
rack1.save()
|
||||||
|
|
||||||
|
device1 = Device(
|
||||||
|
name='TestSwitch1',
|
||||||
|
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
||||||
|
device_role=DeviceRole.objects.get(slug='switch'),
|
||||||
|
site=self.site1,
|
||||||
|
rack=rack1,
|
||||||
|
position=43,
|
||||||
|
face=RACK_FACE_FRONT,
|
||||||
|
)
|
||||||
|
device1.save()
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
rack1.clean()
|
||||||
|
|
||||||
|
def test_rack_group_site(self):
|
||||||
|
|
||||||
|
rack_invalid_group = Rack(
|
||||||
|
name='TestRack2',
|
||||||
|
facility_id='A102',
|
||||||
|
site=self.site1,
|
||||||
|
u_height=42,
|
||||||
|
group=self.group2
|
||||||
|
)
|
||||||
|
rack_invalid_group.save()
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
rack_invalid_group.clean()
|
||||||
|
|
||||||
def test_mount_single_device(self):
|
def test_mount_single_device(self):
|
||||||
|
|
||||||
device1 = Device(
|
device1 = Device(
|
||||||
name='TestSwitch1',
|
name='TestSwitch1',
|
||||||
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
||||||
device_role=DeviceRole.objects.get(slug='switch'),
|
device_role=DeviceRole.objects.get(slug='switch'),
|
||||||
site=self.site,
|
site=self.site1,
|
||||||
rack=self.rack,
|
rack=self.rack,
|
||||||
position=10,
|
position=10,
|
||||||
face=RACK_FACE_REAR,
|
face=RACK_FACE_REAR,
|
||||||
@@ -92,9 +145,118 @@ class RackTestCase(TestCase):
|
|||||||
name='TestPDU',
|
name='TestPDU',
|
||||||
device_role=self.role.get('PDU'),
|
device_role=self.role.get('PDU'),
|
||||||
device_type=self.device_type.get('cc5000'),
|
device_type=self.device_type.get('cc5000'),
|
||||||
site=self.site,
|
site=self.site1,
|
||||||
rack=self.rack,
|
rack=self.rack,
|
||||||
position=None,
|
position=None,
|
||||||
face=None,
|
face=None,
|
||||||
)
|
)
|
||||||
self.assertTrue(pdu)
|
self.assertTrue(pdu)
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
|
||||||
|
self.site = Site.objects.create(
|
||||||
|
name='TestSite1',
|
||||||
|
slug='my-test-site'
|
||||||
|
)
|
||||||
|
self.rack = Rack.objects.create(
|
||||||
|
name='TestRack1',
|
||||||
|
facility_id='A101',
|
||||||
|
site=self.site,
|
||||||
|
u_height=42
|
||||||
|
)
|
||||||
|
self.manufacturer = Manufacturer.objects.create(
|
||||||
|
name='Acme',
|
||||||
|
slug='acme'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=self.manufacturer,
|
||||||
|
model='FrameForwarder 2048',
|
||||||
|
slug='ff2048'
|
||||||
|
)
|
||||||
|
self.role = DeviceRole.objects.create(
|
||||||
|
name='Switch',
|
||||||
|
slug='switch',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_interface_order_natural(self):
|
||||||
|
device1 = Device.objects.create(
|
||||||
|
name='TestSwitch1',
|
||||||
|
device_type=self.device_type,
|
||||||
|
device_role=self.role,
|
||||||
|
site=self.site,
|
||||||
|
rack=self.rack,
|
||||||
|
position=10,
|
||||||
|
face=RACK_FACE_REAR,
|
||||||
|
)
|
||||||
|
interface1 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Ethernet1/3/1'
|
||||||
|
)
|
||||||
|
interface2 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Ethernet1/5/1'
|
||||||
|
)
|
||||||
|
interface3 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Ethernet1/4'
|
||||||
|
)
|
||||||
|
interface4 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Ethernet1/3/2/4'
|
||||||
|
)
|
||||||
|
interface5 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Ethernet1/3/2/1'
|
||||||
|
)
|
||||||
|
interface6 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='Loopback1'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(Interface.objects.all().order_naturally()),
|
||||||
|
[interface1, interface5, interface4, interface3, interface2, interface6]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_interface_order_natural_subinterfaces(self):
|
||||||
|
device1 = Device.objects.create(
|
||||||
|
name='TestSwitch1',
|
||||||
|
device_type=self.device_type,
|
||||||
|
device_role=self.role,
|
||||||
|
site=self.site,
|
||||||
|
rack=self.rack,
|
||||||
|
position=10,
|
||||||
|
face=RACK_FACE_REAR,
|
||||||
|
)
|
||||||
|
interface1 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0/0/3'
|
||||||
|
)
|
||||||
|
interface2 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0/0/2.2'
|
||||||
|
)
|
||||||
|
interface3 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0/0/0.120'
|
||||||
|
)
|
||||||
|
interface4 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0/0/0'
|
||||||
|
)
|
||||||
|
interface5 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0/0/1.117'
|
||||||
|
)
|
||||||
|
interface6 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='GigabitEthernet0'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
list(Interface.objects.all().order_naturally()),
|
||||||
|
[interface4, interface3, interface5, interface2, interface1, interface6]
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ from django.conf.urls import url
|
|||||||
from extras.views import ImageAttachmentEditView
|
from extras.views import ImageAttachmentEditView
|
||||||
from ipam.views import ServiceCreateView
|
from ipam.views import ServiceCreateView
|
||||||
from secrets.views import secret_add
|
from secrets.views import secret_add
|
||||||
from .models import Device, Rack, Site
|
|
||||||
from . import views
|
from . import views
|
||||||
|
from .models import Device, Rack, Site
|
||||||
|
|
||||||
app_name = 'dcim'
|
app_name = 'dcim'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -15,6 +14,7 @@ urlpatterns = [
|
|||||||
# Regions
|
# Regions
|
||||||
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
|
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
|
||||||
url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
|
url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
|
||||||
|
url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
|
||||||
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
|
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
|
||||||
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
|
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
|
||||||
|
|
||||||
@@ -31,17 +31,20 @@ urlpatterns = [
|
|||||||
# Rack groups
|
# Rack groups
|
||||||
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
|
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
|
||||||
url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
|
url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
|
||||||
|
url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
|
||||||
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
|
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
|
||||||
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
|
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
|
||||||
|
|
||||||
# Rack roles
|
# Rack roles
|
||||||
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
|
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||||
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
|
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
|
||||||
|
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
|
||||||
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
|
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
|
||||||
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
||||||
|
|
||||||
# Rack reservations
|
# Rack reservations
|
||||||
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
||||||
|
url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
|
||||||
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
||||||
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
||||||
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||||
@@ -62,12 +65,14 @@ urlpatterns = [
|
|||||||
# Manufacturers
|
# Manufacturers
|
||||||
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||||
url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
|
url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
|
||||||
|
url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
|
||||||
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
|
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
|
||||||
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
|
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
|
||||||
|
|
||||||
# Device types
|
# Device types
|
||||||
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
|
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
|
||||||
url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
|
url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
|
||||||
|
url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
|
||||||
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
|
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
|
||||||
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
|
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
|
||||||
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
|
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
|
||||||
@@ -102,12 +107,14 @@ urlpatterns = [
|
|||||||
# Device roles
|
# Device roles
|
||||||
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
|
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
|
||||||
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
|
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
|
||||||
|
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
|
||||||
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
|
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
|
||||||
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
|
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
|
||||||
|
|
||||||
# Platforms
|
# Platforms
|
||||||
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
|
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
|
||||||
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
|
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
|
||||||
|
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
|
||||||
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
|
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
|
||||||
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
|
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
|
||||||
|
|
||||||
@@ -126,15 +133,15 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||||
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
|
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
|
||||||
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
|
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
|
||||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
|
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
|
||||||
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||||
|
|
||||||
# Console ports
|
# Console ports
|
||||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
|
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
|
||||||
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
|
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
|
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||||
|
|
||||||
@@ -143,17 +150,18 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
|
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
|
||||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
|
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
|
||||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||||
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
|
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
|
||||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
|
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
|
||||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||||
|
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
|
||||||
|
|
||||||
# Power ports
|
# Power ports
|
||||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
|
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
|
||||||
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
|
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
|
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||||
|
|
||||||
@@ -162,10 +170,11 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
|
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
|
||||||
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
|
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
|
||||||
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
|
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
|
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||||
|
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
|
||||||
|
|
||||||
# Interfaces
|
# Interfaces
|
||||||
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||||
@@ -173,10 +182,12 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||||
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
|
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
|
||||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
|
||||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
|
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||||
|
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
|
||||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||||
|
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||||
|
|
||||||
# Device bays
|
# Device bays
|
||||||
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||||
@@ -184,13 +195,18 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
|
||||||
|
url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
|
||||||
|
|
||||||
# Inventory items
|
# Inventory items
|
||||||
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
|
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
|
||||||
|
url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
|
||||||
|
url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
|
||||||
|
url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
|
||||||
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
|
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
|
||||||
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
|
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
|
||||||
|
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
|
||||||
|
|
||||||
# Console/power/interface connections
|
# Console/power/interface connections
|
||||||
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
||||||
@@ -200,4 +216,12 @@ urlpatterns = [
|
|||||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
||||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||||
|
|
||||||
|
# Virtual chassis
|
||||||
|
url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
|
||||||
|
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
|
||||||
|
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
|
||||||
|
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
|
||||||
|
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
|
||||||
|
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
1197
netbox/dcim/views.py
1197
netbox/dcim/views.py
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,18 @@ from django.utils.safestring import mark_safe
|
|||||||
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
|
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
|
||||||
|
|
||||||
|
|
||||||
|
def order_content_types(field):
|
||||||
|
"""
|
||||||
|
Order the list of available ContentTypes by application
|
||||||
|
"""
|
||||||
|
queryset = field.queryset.order_by('app_label', 'model')
|
||||||
|
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom fields
|
||||||
|
#
|
||||||
|
|
||||||
class CustomFieldForm(forms.ModelForm):
|
class CustomFieldForm(forms.ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -16,9 +28,7 @@ class CustomFieldForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Organize the available ContentTypes
|
order_content_types(self.fields['obj_type'])
|
||||||
queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model')
|
|
||||||
self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceAdmin(admin.TabularInline):
|
class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||||
@@ -29,23 +39,50 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
|
|||||||
@admin.register(CustomField)
|
@admin.register(CustomField)
|
||||||
class CustomFieldAdmin(admin.ModelAdmin):
|
class CustomFieldAdmin(admin.ModelAdmin):
|
||||||
inlines = [CustomFieldChoiceAdmin]
|
inlines = [CustomFieldChoiceAdmin]
|
||||||
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
|
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
|
||||||
form = CustomFieldForm
|
form = CustomFieldForm
|
||||||
|
|
||||||
def models(self, obj):
|
def models(self, obj):
|
||||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Graphs
|
||||||
|
#
|
||||||
|
|
||||||
@admin.register(Graph)
|
@admin.register(Graph)
|
||||||
class GraphAdmin(admin.ModelAdmin):
|
class GraphAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'type', 'weight', 'source']
|
list_display = ['name', 'type', 'weight', 'source']
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Export templates
|
||||||
|
#
|
||||||
|
|
||||||
|
class ExportTemplateForm(forms.ModelForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ExportTemplate
|
||||||
|
exclude = []
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ExportTemplateForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Format ContentType choices
|
||||||
|
order_content_types(self.fields['content_type'])
|
||||||
|
self.fields['content_type'].choices.insert(0, ('', '---------'))
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ExportTemplate)
|
@admin.register(ExportTemplate)
|
||||||
class ExportTemplateAdmin(admin.ModelAdmin):
|
class ExportTemplateAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
|
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
|
||||||
|
form = ExportTemplateForm
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Topology maps
|
||||||
|
#
|
||||||
|
|
||||||
@admin.register(TopologyMap)
|
@admin.register(TopologyMap)
|
||||||
class TopologyMapAdmin(admin.ModelAdmin):
|
class TopologyMapAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'site']
|
list_display = ['name', 'slug', 'site']
|
||||||
@@ -54,6 +91,10 @@ class TopologyMapAdmin(admin.ModelAdmin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# User actions
|
||||||
|
#
|
||||||
|
|
||||||
@admin.register(UserAction)
|
@admin.register(UserAction)
|
||||||
class UserActionAdmin(admin.ModelAdmin):
|
class UserActionAdmin(admin.ModelAdmin):
|
||||||
actions = None
|
actions = None
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
from datetime import datetime
|
||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from extras.models import (
|
from extras.constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT
|
||||||
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue,
|
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
|
||||||
)
|
|
||||||
from utilities.api import ValidatedModelSerializer
|
from utilities.api import ValidatedModelSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -29,34 +28,56 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
|
|||||||
|
|
||||||
for field_name, value in data.items():
|
for field_name, value in data.items():
|
||||||
|
|
||||||
|
try:
|
||||||
cf = custom_fields[field_name]
|
cf = custom_fields[field_name]
|
||||||
|
except KeyError:
|
||||||
|
raise ValidationError(
|
||||||
|
"Invalid custom field for {} objects: {}".format(content_type, field_name)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate custom field name
|
# Data validation
|
||||||
if field_name not in custom_fields:
|
if value not in [None, '']:
|
||||||
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
|
|
||||||
|
# Validate integer
|
||||||
|
if cf.type == CF_TYPE_INTEGER:
|
||||||
|
try:
|
||||||
|
int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(
|
||||||
|
"Invalid value for integer field {}: {}".format(field_name, value)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate boolean
|
# Validate boolean
|
||||||
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
||||||
raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value))
|
raise ValidationError(
|
||||||
|
"Invalid value for boolean field {}: {}".format(field_name, value)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate date
|
# Validate date
|
||||||
if cf.type == CF_TYPE_DATE:
|
if cf.type == CF_TYPE_DATE:
|
||||||
try:
|
try:
|
||||||
datetime.strptime(value, '%Y-%m-%d')
|
datetime.strptime(value, '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(
|
raise ValidationError(
|
||||||
field_name, value
|
"Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
|
||||||
))
|
)
|
||||||
|
|
||||||
# Validate selected choice
|
# Validate selected choice
|
||||||
if cf.type == CF_TYPE_SELECT:
|
if cf.type == CF_TYPE_SELECT:
|
||||||
try:
|
try:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name))
|
raise ValidationError(
|
||||||
|
"{}: Choice selections must be passed as integers.".format(field_name)
|
||||||
|
)
|
||||||
valid_choices = [c.pk for c in cf.choices.all()]
|
valid_choices = [c.pk for c in cf.choices.all()]
|
||||||
if value not in valid_choices:
|
if value not in valid_choices:
|
||||||
raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
|
raise ValidationError(
|
||||||
|
"Invalid choice for field {}: {}".format(field_name, value)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cf.required:
|
||||||
|
raise ValidationError("Required field {} cannot be empty.".format(field_name))
|
||||||
|
|
||||||
# Check for missing required fields
|
# Check for missing required fields
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
||||||
from dcim.models import Device, Rack, Site
|
from dcim.models import Device, Rack, Site
|
||||||
from extras.models import (
|
from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES
|
||||||
ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
|
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||||
)
|
|
||||||
from users.api.serializers import NestedUserSerializer
|
from users.api.serializers import NestedUserSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
|
||||||
|
|
||||||
@@ -127,6 +125,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reports
|
||||||
|
#
|
||||||
|
|
||||||
|
class ReportResultSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReportResult
|
||||||
|
fields = ['created', 'user', 'failed', 'data']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedReportResultSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(
|
||||||
|
view_name='extras-api:report-detail',
|
||||||
|
lookup_field='report',
|
||||||
|
lookup_url_kwarg='pk'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReportResult
|
||||||
|
fields = ['url', 'created', 'user', 'failed']
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
result = NestedReportResultSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportDetailSerializer(ReportSerializer):
|
||||||
|
result = ReportResultSerializer()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# User actions
|
# User actions
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class ExtrasRootView(routers.APIRootView):
|
|||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.APIRootView = ExtrasRootView
|
router.APIRootView = ExtrasRootView
|
||||||
|
|
||||||
|
# Field choices
|
||||||
|
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice')
|
||||||
|
|
||||||
# Graphs
|
# Graphs
|
||||||
router.register(r'graphs', views.GraphViewSet)
|
router.register(r'graphs', views.GraphViewSet)
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet)
|
|||||||
# Image attachments
|
# Image attachments
|
||||||
router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
||||||
|
|
||||||
|
# Reports
|
||||||
|
router.register(r'reports', views.ReportViewSet, base_name='report')
|
||||||
|
|
||||||
# Recent activity
|
# Recent activity
|
||||||
router.register(r'recent-activity', views.RecentActivityViewSet)
|
router.register(r'recent-activity', views.RecentActivityViewSet)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework.decorators import detail_route
|
|
||||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http import HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework.decorators import detail_route
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||||
|
|
||||||
from extras import filters
|
from extras import filters
|
||||||
from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
|
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||||
from utilities.api import WritableSerializerMixin
|
from extras.reports import get_report, get_reports
|
||||||
|
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Field choices
|
||||||
|
#
|
||||||
|
|
||||||
|
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
|
||||||
|
fields = (
|
||||||
|
(CustomField, ['type']),
|
||||||
|
(Graph, ['type']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom fields
|
||||||
|
#
|
||||||
|
|
||||||
class CustomFieldModelViewSet(ModelViewSet):
|
class CustomFieldModelViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
Include the applicable set of CustomFields in the ModelViewSet context.
|
Include the applicable set of CustomFields in the ModelViewSet context.
|
||||||
@@ -43,20 +60,32 @@ class CustomFieldModelViewSet(ModelViewSet):
|
|||||||
return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
|
return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
|
||||||
|
|
||||||
|
|
||||||
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
#
|
||||||
|
# Graphs
|
||||||
|
#
|
||||||
|
|
||||||
|
class GraphViewSet(ModelViewSet):
|
||||||
queryset = Graph.objects.all()
|
queryset = Graph.objects.all()
|
||||||
serializer_class = serializers.GraphSerializer
|
serializer_class = serializers.GraphSerializer
|
||||||
write_serializer_class = serializers.WritableGraphSerializer
|
write_serializer_class = serializers.WritableGraphSerializer
|
||||||
filter_class = filters.GraphFilter
|
filter_class = filters.GraphFilter
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
#
|
||||||
|
# Export templates
|
||||||
|
#
|
||||||
|
|
||||||
|
class ExportTemplateViewSet(ModelViewSet):
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
serializer_class = serializers.ExportTemplateSerializer
|
serializer_class = serializers.ExportTemplateSerializer
|
||||||
filter_class = filters.ExportTemplateFilter
|
filter_class = filters.ExportTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
#
|
||||||
|
# Topology maps
|
||||||
|
#
|
||||||
|
|
||||||
|
class TopologyMapViewSet(ModelViewSet):
|
||||||
queryset = TopologyMap.objects.select_related('site')
|
queryset = TopologyMap.objects.select_related('site')
|
||||||
serializer_class = serializers.TopologyMapSerializer
|
serializer_class = serializers.TopologyMapSerializer
|
||||||
write_serializer_class = serializers.WritableTopologyMapSerializer
|
write_serializer_class = serializers.WritableTopologyMapSerializer
|
||||||
@@ -70,7 +99,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = tmap.render(img_format=img_format)
|
data = tmap.render(img_format=img_format)
|
||||||
except:
|
except Exception:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
"There was an error generating the requested graph. Ensure that the GraphViz executables have been "
|
"There was an error generating the requested graph. Ensure that the GraphViz executables have been "
|
||||||
"installed correctly."
|
"installed correctly."
|
||||||
@@ -82,12 +111,96 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
#
|
||||||
|
# Image attachments
|
||||||
|
#
|
||||||
|
|
||||||
|
class ImageAttachmentViewSet(ModelViewSet):
|
||||||
queryset = ImageAttachment.objects.all()
|
queryset = ImageAttachment.objects.all()
|
||||||
serializer_class = serializers.ImageAttachmentSerializer
|
serializer_class = serializers.ImageAttachmentSerializer
|
||||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reports
|
||||||
|
#
|
||||||
|
|
||||||
|
class ReportViewSet(ViewSet):
|
||||||
|
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
lookup_value_regex = '[^/]+' # Allow dots
|
||||||
|
|
||||||
|
def _retrieve_report(self, pk):
|
||||||
|
|
||||||
|
# Read the PK as "<module>.<report>"
|
||||||
|
if '.' not in pk:
|
||||||
|
raise Http404
|
||||||
|
module_name, report_name = pk.split('.', 1)
|
||||||
|
|
||||||
|
# Raise a 404 on an invalid Report module/name
|
||||||
|
report = get_report(module_name, report_name)
|
||||||
|
if report is None:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def list(self, request):
|
||||||
|
"""
|
||||||
|
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||||
|
"""
|
||||||
|
report_list = []
|
||||||
|
|
||||||
|
# Iterate through all available Reports.
|
||||||
|
for module_name, reports in get_reports():
|
||||||
|
for report in reports:
|
||||||
|
|
||||||
|
# Attach the relevant ReportResult (if any) to each Report.
|
||||||
|
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
|
||||||
|
report_list.append(report)
|
||||||
|
|
||||||
|
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||||
|
'request': request,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def retrieve(self, request, pk):
|
||||||
|
"""
|
||||||
|
Retrieve a single Report identified as "<module>.<report>".
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Retrieve the Report and ReportResult, if any.
|
||||||
|
report = self._retrieve_report(pk)
|
||||||
|
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||||
|
|
||||||
|
serializer = serializers.ReportDetailSerializer(report)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@detail_route(methods=['post'])
|
||||||
|
def run(self, request, pk):
|
||||||
|
"""
|
||||||
|
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check that the user has permission to run reports.
|
||||||
|
if not request.user.has_perm('extras.add_reportresult'):
|
||||||
|
raise PermissionDenied("This user does not have permission to run reports.")
|
||||||
|
|
||||||
|
# Retrieve and run the Report. This will create a new ReportResult.
|
||||||
|
report = self._retrieve_report(pk)
|
||||||
|
report.run()
|
||||||
|
|
||||||
|
serializer = serializers.ReportDetailSerializer(report)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# User activity
|
||||||
|
#
|
||||||
|
|
||||||
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
List all UserActions to provide a log of recent activity.
|
List all UserActions to provide a log of recent activity.
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
# Models which support custom fields
|
# Models which support custom fields
|
||||||
CUSTOMFIELD_MODELS = (
|
CUSTOMFIELD_MODELS = (
|
||||||
|
'provider', 'circuit', # Circuits
|
||||||
'site', 'rack', 'devicetype', 'device', # DCIM
|
'site', 'rack', 'devicetype', 'device', # DCIM
|
||||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
|
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
|
||||||
'provider', 'circuit', # Circuits
|
'tenant', # Tenancy
|
||||||
'tenant', # Tenants
|
'cluster', 'virtualmachine', # Virtualization
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom field types
|
# Custom field types
|
||||||
@@ -25,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
|
|||||||
(CF_TYPE_SELECT, 'Selection'),
|
(CF_TYPE_SELECT, 'Selection'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Custom field filter logic choices
|
||||||
|
CF_FILTER_DISABLED = 0
|
||||||
|
CF_FILTER_LOOSE = 1
|
||||||
|
CF_FILTER_EXACT = 2
|
||||||
|
CF_FILTER_CHOICES = (
|
||||||
|
(CF_FILTER_DISABLED, 'Disabled'),
|
||||||
|
(CF_FILTER_LOOSE, 'Loose'),
|
||||||
|
(CF_FILTER_EXACT, 'Exact'),
|
||||||
|
)
|
||||||
|
|
||||||
# Graph types
|
# Graph types
|
||||||
GRAPH_TYPE_INTERFACE = 100
|
GRAPH_TYPE_INTERFACE = 100
|
||||||
GRAPH_TYPE_PROVIDER = 200
|
GRAPH_TYPE_PROVIDER = 200
|
||||||
@@ -37,12 +48,24 @@ GRAPH_TYPE_CHOICES = (
|
|||||||
|
|
||||||
# Models which support export templates
|
# Models which support export templates
|
||||||
EXPORTTEMPLATE_MODELS = [
|
EXPORTTEMPLATE_MODELS = [
|
||||||
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM
|
|
||||||
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
|
|
||||||
'provider', 'circuit', # Circuits
|
'provider', 'circuit', # Circuits
|
||||||
'tenant', # Tenants
|
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
|
||||||
|
'consoleport', 'powerport', 'interfaceconnection', # DCIM
|
||||||
|
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
|
||||||
|
'tenant', # Tenancy
|
||||||
|
'cluster', 'virtualmachine', # Virtualization
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Topology map types
|
||||||
|
TOPOLOGYMAP_TYPE_NETWORK = 1
|
||||||
|
TOPOLOGYMAP_TYPE_CONSOLE = 2
|
||||||
|
TOPOLOGYMAP_TYPE_POWER = 3
|
||||||
|
TOPOLOGYMAP_TYPE_CHOICES = (
|
||||||
|
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
|
||||||
|
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
|
||||||
|
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
|
||||||
|
)
|
||||||
|
|
||||||
# User action types
|
# User action types
|
||||||
ACTION_CREATE = 1
|
ACTION_CREATE = 1
|
||||||
ACTION_IMPORT = 2
|
ACTION_IMPORT = 2
|
||||||
@@ -60,3 +83,17 @@ ACTION_CHOICES = (
|
|||||||
(ACTION_DELETE, 'deleted'),
|
(ACTION_DELETE, 'deleted'),
|
||||||
(ACTION_BULK_DELETE, 'bulk deleted'),
|
(ACTION_BULK_DELETE, 'bulk deleted'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Report logging levels
|
||||||
|
LOG_DEFAULT = 0
|
||||||
|
LOG_SUCCESS = 10
|
||||||
|
LOG_INFO = 20
|
||||||
|
LOG_WARNING = 30
|
||||||
|
LOG_FAILURE = 40
|
||||||
|
LOG_LEVEL_CODES = {
|
||||||
|
LOG_DEFAULT: 'default',
|
||||||
|
LOG_SUCCESS: 'success',
|
||||||
|
LOG_INFO: 'info',
|
||||||
|
LOG_WARNING: 'warning',
|
||||||
|
LOG_FAILURE: 'failure',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from .models import CF_TYPE_SELECT, CustomField, Graph, ExportTemplate, TopologyMap, UserAction
|
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
|
||||||
|
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilter(django_filters.Filter):
|
class CustomFieldFilter(django_filters.Filter):
|
||||||
@@ -14,26 +14,47 @@ class CustomFieldFilter(django_filters.Filter):
|
|||||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cf_type, *args, **kwargs):
|
def __init__(self, custom_field, *args, **kwargs):
|
||||||
self.cf_type = cf_type
|
self.cf_type = custom_field.type
|
||||||
|
self.filter_logic = custom_field.filter_logic
|
||||||
super(CustomFieldFilter, self).__init__(*args, **kwargs)
|
super(CustomFieldFilter, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter(self, queryset, value):
|
def filter(self, queryset, value):
|
||||||
|
|
||||||
# Skip filter on empty value
|
# Skip filter on empty value
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
# Treat 0 as None for Select fields
|
|
||||||
|
# Selection fields get special treatment (values must be integers)
|
||||||
|
if self.cf_type == CF_TYPE_SELECT:
|
||||||
try:
|
try:
|
||||||
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
|
# Treat 0 as None
|
||||||
|
if int(value) == 0:
|
||||||
return queryset.exclude(
|
return queryset.exclude(
|
||||||
custom_field_values__field__name=self.name,
|
custom_field_values__field__name=self.name,
|
||||||
)
|
)
|
||||||
except ValueError:
|
# Match on exact CustomFieldChoice PK
|
||||||
pass
|
else:
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
custom_field_values__field__name=self.name,
|
custom_field_values__field__name=self.name,
|
||||||
custom_field_values__serialized_value__icontains=value,
|
custom_field_values__serialized_value=value,
|
||||||
)
|
)
|
||||||
|
except ValueError:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Apply the assigned filter logic (exact or loose)
|
||||||
|
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
custom_field_values__field__name=self.name,
|
||||||
|
custom_field_values__serialized_value=value
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
custom_field_values__field__name=self.name,
|
||||||
|
custom_field_values__serialized_value__icontains=value
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||||
@@ -45,9 +66,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
|||||||
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
|
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||||
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
|
custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
|
||||||
for cf in custom_fields:
|
for cf in custom_fields:
|
||||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
|
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
|
||||||
|
|
||||||
|
|
||||||
class GraphFilter(django_filters.FilterSet):
|
class GraphFilter(django_filters.FilterSet):
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
|
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
|
||||||
from .models import (
|
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
|
||||||
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue,
|
from .models import CustomField, CustomFieldValue, ImageAttachment
|
||||||
ImageAttachment,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
|
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
|
||||||
@@ -16,17 +16,17 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
|||||||
Retrieve all CustomFields applicable to the given ContentType
|
Retrieve all CustomFields applicable to the given ContentType
|
||||||
"""
|
"""
|
||||||
field_dict = OrderedDict()
|
field_dict = OrderedDict()
|
||||||
kwargs = {'obj_type': content_type}
|
custom_fields = CustomField.objects.filter(obj_type=content_type)
|
||||||
if filterable_only:
|
if filterable_only:
|
||||||
kwargs['is_filterable'] = True
|
custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
|
||||||
custom_fields = CustomField.objects.filter(**kwargs)
|
|
||||||
|
|
||||||
for cf in custom_fields:
|
for cf in custom_fields:
|
||||||
field_name = 'cf_{}'.format(str(cf.name))
|
field_name = 'cf_{}'.format(str(cf.name))
|
||||||
|
initial = cf.default if not bulk_edit else None
|
||||||
|
|
||||||
# Integer
|
# Integer
|
||||||
if cf.type == CF_TYPE_INTEGER:
|
if cf.type == CF_TYPE_INTEGER:
|
||||||
field = forms.IntegerField(required=cf.required, initial=cf.default)
|
field = forms.IntegerField(required=cf.required, initial=initial)
|
||||||
|
|
||||||
# Boolean
|
# Boolean
|
||||||
elif cf.type == CF_TYPE_BOOLEAN:
|
elif cf.type == CF_TYPE_BOOLEAN:
|
||||||
@@ -35,33 +35,41 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
|||||||
(1, 'True'),
|
(1, 'True'),
|
||||||
(0, 'False'),
|
(0, 'False'),
|
||||||
)
|
)
|
||||||
if cf.default.lower() in ['true', 'yes', '1']:
|
if initial is not None and initial.lower() in ['true', 'yes', '1']:
|
||||||
initial = 1
|
initial = 1
|
||||||
elif cf.default.lower() in ['false', 'no', '0']:
|
elif initial is not None and initial.lower() in ['false', 'no', '0']:
|
||||||
initial = 0
|
initial = 0
|
||||||
else:
|
else:
|
||||||
initial = None
|
initial = None
|
||||||
field = forms.NullBooleanField(required=cf.required, initial=initial,
|
field = forms.NullBooleanField(
|
||||||
widget=forms.Select(choices=choices))
|
required=cf.required, initial=initial, widget=forms.Select(choices=choices)
|
||||||
|
)
|
||||||
|
|
||||||
# Date
|
# Date
|
||||||
elif cf.type == CF_TYPE_DATE:
|
elif cf.type == CF_TYPE_DATE:
|
||||||
field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD")
|
field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
|
||||||
|
|
||||||
# Select
|
# Select
|
||||||
elif cf.type == CF_TYPE_SELECT:
|
elif cf.type == CF_TYPE_SELECT:
|
||||||
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
||||||
if not cf.required or bulk_edit or filterable_only:
|
if not cf.required or bulk_edit or filterable_only:
|
||||||
choices = [(None, '---------')] + choices
|
choices = [(None, '---------')] + choices
|
||||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
|
# Check for a default choice
|
||||||
|
default_choice = None
|
||||||
|
if initial:
|
||||||
|
try:
|
||||||
|
default_choice = cf.choices.get(value=initial).pk
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
pass
|
||||||
|
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
elif cf.type == CF_TYPE_URL:
|
elif cf.type == CF_TYPE_URL:
|
||||||
field = LaxURLField(required=cf.required, initial=cf.default)
|
field = LaxURLField(required=cf.required, initial=initial)
|
||||||
|
|
||||||
# Text
|
# Text
|
||||||
else:
|
else:
|
||||||
field = forms.CharField(max_length=255, required=cf.required, initial=cf.default)
|
field = forms.CharField(max_length=255, required=cf.required, initial=initial)
|
||||||
|
|
||||||
field.model = cf
|
field.model = cf
|
||||||
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
|
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ from django.conf import settings
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
|
|
||||||
|
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
|
||||||
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users']
|
|
||||||
|
|
||||||
BANNER_TEXT = """### NetBox interactive shell ({node})
|
BANNER_TEXT = """### NetBox interactive shell ({node})
|
||||||
### Python {python} | Django {django} | NetBox {netbox}
|
### Python {python} | Django {django} | NetBox {netbox}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from ncclient.transport.errors import AuthenticationError
|
|
||||||
from paramiko import AuthenticationException
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from ncclient.transport.errors import AuthenticationError
|
||||||
|
from paramiko import AuthenticationException
|
||||||
|
|
||||||
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
|
from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -41,7 +41,7 @@ class Command(BaseCommand):
|
|||||||
self.password = getpass("Password: ")
|
self.password = getpass("Password: ")
|
||||||
|
|
||||||
# Attempt to inventory only active devices
|
# Attempt to inventory only active devices
|
||||||
device_list = Device.objects.filter(status=STATUS_ACTIVE)
|
device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
|
||||||
|
|
||||||
# --site: Include only devices belonging to specified site(s)
|
# --site: Include only devices belonging to specified site(s)
|
||||||
if options['site']:
|
if options['site']:
|
||||||
|
|||||||
46
netbox/extras/management/commands/runreport.py
Normal file
46
netbox/extras/management/commands/runreport.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from extras.reports import get_reports
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Run a report to validate data in NetBox"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('reports', nargs='+', help="Report(s) to run")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
# Gather all available reports
|
||||||
|
reports = get_reports()
|
||||||
|
|
||||||
|
# Run reports
|
||||||
|
for module_name, report_list in reports:
|
||||||
|
for report in report_list:
|
||||||
|
if module_name in options['reports'] or report.full_name in options['reports']:
|
||||||
|
|
||||||
|
# Run the report and create a new ReportResult
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||||
|
)
|
||||||
|
report.run()
|
||||||
|
|
||||||
|
# Report on success/failure
|
||||||
|
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
|
||||||
|
for test_name, attrs in report.result.data.items():
|
||||||
|
self.stdout.write(
|
||||||
|
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||||
|
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap things up
|
||||||
|
self.stdout.write(
|
||||||
|
"[{:%H:%M:%S}] Finished".format(timezone.now())
|
||||||
|
)
|
||||||
@@ -4,14 +4,6 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
from extras.models import TopologyMap
|
|
||||||
|
|
||||||
|
|
||||||
def commas_to_semicolons(apps, schema_editor):
|
|
||||||
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
|
|
||||||
tm.device_patterns = tm.device_patterns.replace(',', ';')
|
|
||||||
tm.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
|
|||||||
name='device_patterns',
|
name='device_patterns',
|
||||||
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
|
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
|
||||||
),
|
),
|
||||||
migrations.RunPython(commas_to_semicolons),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='customfield',
|
model_name='customfield',
|
||||||
name='default',
|
name='default',
|
||||||
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
|
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='customfield',
|
model_name='customfield',
|
||||||
|
|||||||
54
netbox/extras/migrations/0008_reports.py
Normal file
54
netbox/extras/migrations/0008_reports.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-09-26 21:25
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
from django.db import connection, migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
|
||||||
|
|
||||||
|
def verify_postgresql_version(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Verify that PostgreSQL is version 9.4 or higher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT VERSION()")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||||
|
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||||
|
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||||
|
|
||||||
|
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
|
||||||
|
except OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('extras', '0007_unicode_literals'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(verify_postgresql_version),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReportResult',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('report', models.CharField(max_length=255, unique=True)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('failed', models.BooleanField()),
|
||||||
|
('data', django.contrib.postgres.fields.jsonb.JSONField()),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['report'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
20
netbox/extras/migrations/0009_topologymap_type.py
Normal file
20
netbox/extras/migrations/0009_topologymap_type.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-15 16:28
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0008_reports'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='topologymap',
|
||||||
|
name='type',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
51
netbox/extras/migrations/0010_customfield_filter_logic.py
Normal file
51
netbox/extras/migrations/0010_customfield_filter_logic.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-21 19:48
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
|
||||||
|
|
||||||
|
|
||||||
|
def is_filterable_to_filter_logic(apps, schema_editor):
|
||||||
|
CustomField = apps.get_model('extras', 'CustomField')
|
||||||
|
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
|
||||||
|
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
|
||||||
|
# Select fields match on primary key only
|
||||||
|
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_logic_to_is_filterable(apps, schema_editor):
|
||||||
|
CustomField = apps.get_model('extras', 'CustomField')
|
||||||
|
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
|
||||||
|
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0009_topologymap_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='filter_logic',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='required',
|
||||||
|
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='weight',
|
||||||
|
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='is_filterable',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import graphviz
|
|
||||||
|
|
||||||
|
import graphviz
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -14,6 +16,7 @@ from django.template import Template, Context
|
|||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||||
from utilities.utils import foreground_color
|
from utilities.utils import foreground_color
|
||||||
from .constants import *
|
from .constants import *
|
||||||
|
|
||||||
@@ -52,22 +55,48 @@ class CustomFieldModel(object):
|
|||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class CustomField(models.Model):
|
class CustomField(models.Model):
|
||||||
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
|
obj_type = models.ManyToManyField(
|
||||||
|
to=ContentType,
|
||||||
|
related_name='custom_fields',
|
||||||
|
verbose_name='Object(s)',
|
||||||
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
|
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
|
||||||
help_text="The object(s) to which this field applies.")
|
help_text='The object(s) to which this field applies.'
|
||||||
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
|
)
|
||||||
name = models.CharField(max_length=50, unique=True)
|
type = models.PositiveSmallIntegerField(
|
||||||
label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
|
choices=CUSTOMFIELD_TYPE_CHOICES,
|
||||||
"provided, the field's name will be used)")
|
default=CF_TYPE_TEXT
|
||||||
description = models.CharField(max_length=100, blank=True)
|
)
|
||||||
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
|
name = models.CharField(
|
||||||
"new objects or editing an existing object.")
|
max_length=50,
|
||||||
is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
|
unique=True
|
||||||
default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
|
)
|
||||||
"\"false\" for booleans. N/A for selection "
|
label = models.CharField(
|
||||||
"fields.")
|
max_length=50,
|
||||||
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
|
blank=True,
|
||||||
"form")
|
help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
|
||||||
|
)
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
required = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='If true, this field is required when creating new objects or editing an existing object.'
|
||||||
|
)
|
||||||
|
filter_logic = models.PositiveSmallIntegerField(
|
||||||
|
choices=CF_FILTER_CHOICES,
|
||||||
|
default=CF_FILTER_LOOSE,
|
||||||
|
help_text="Loose matches any instance of a given string; exact matches the entire field."
|
||||||
|
)
|
||||||
|
default = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text='Default value for the field. Use "true" or "false" for booleans.'
|
||||||
|
)
|
||||||
|
weight = models.PositiveSmallIntegerField(
|
||||||
|
default=100,
|
||||||
|
help_text='Fields with higher weights appear lower in a form.'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['weight', 'name']
|
ordering = ['weight', 'name']
|
||||||
@@ -98,7 +127,7 @@ class CustomField(models.Model):
|
|||||||
"""
|
"""
|
||||||
Convert a string into the object it represents depending on the type of field
|
Convert a string into the object it represents depending on the type of field
|
||||||
"""
|
"""
|
||||||
if serialized_value is '':
|
if serialized_value == '':
|
||||||
return None
|
return None
|
||||||
if self.type == CF_TYPE_INTEGER:
|
if self.type == CF_TYPE_INTEGER:
|
||||||
return int(serialized_value)
|
return int(serialized_value)
|
||||||
@@ -221,19 +250,25 @@ class ExportTemplate(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{}: {}'.format(self.content_type, self.name)
|
return '{}: {}'.format(self.content_type, self.name)
|
||||||
|
|
||||||
def to_response(self, context_dict, filename):
|
def render_to_response(self, queryset):
|
||||||
"""
|
"""
|
||||||
Render the template to an HTTP response, delivered as a named file attachment
|
Render the template to an HTTP response, delivered as a named file attachment
|
||||||
"""
|
"""
|
||||||
template = Template(self.template_code)
|
template = Template(self.template_code)
|
||||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||||
output = template.render(Context(context_dict))
|
output = template.render(Context({'queryset': queryset}))
|
||||||
|
|
||||||
# Replace CRLF-style line terminators
|
# Replace CRLF-style line terminators
|
||||||
output = output.replace('\r\n', '\n')
|
output = output.replace('\r\n', '\n')
|
||||||
|
|
||||||
|
# Build the response
|
||||||
response = HttpResponse(output, content_type=mime_type)
|
response = HttpResponse(output, content_type=mime_type)
|
||||||
if self.file_extension:
|
filename = 'netbox_{}{}'.format(
|
||||||
filename += '.{}'.format(self.file_extension)
|
queryset.model._meta.verbose_name_plural,
|
||||||
|
'.{}'.format(self.file_extension) if self.file_extension else ''
|
||||||
|
)
|
||||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -245,7 +280,17 @@ class ExportTemplate(models.Model):
|
|||||||
class TopologyMap(models.Model):
|
class TopologyMap(models.Model):
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
|
type = models.PositiveSmallIntegerField(
|
||||||
|
choices=TOPOLOGYMAP_TYPE_CHOICES,
|
||||||
|
default=TOPOLOGYMAP_TYPE_NETWORK
|
||||||
|
)
|
||||||
|
site = models.ForeignKey(
|
||||||
|
to='dcim.Site',
|
||||||
|
related_name='topology_maps',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
device_patterns = models.TextField(
|
device_patterns = models.TextField(
|
||||||
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
|
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
|
||||||
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
|
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
|
||||||
@@ -267,26 +312,34 @@ class TopologyMap(models.Model):
|
|||||||
|
|
||||||
def render(self, img_format='png'):
|
def render(self, img_format='png'):
|
||||||
|
|
||||||
from circuits.models import CircuitTermination
|
from dcim.models import Device
|
||||||
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
|
|
||||||
|
|
||||||
# Construct the graph
|
# Construct the graph
|
||||||
graph = graphviz.Graph()
|
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
|
||||||
graph.graph_attr['ranksep'] = '1'
|
G = graphviz.Graph
|
||||||
|
else:
|
||||||
|
G = graphviz.Digraph
|
||||||
|
self.graph = G()
|
||||||
|
self.graph.graph_attr['ranksep'] = '1'
|
||||||
|
seen = set()
|
||||||
for i, device_set in enumerate(self.device_sets):
|
for i, device_set in enumerate(self.device_sets):
|
||||||
|
|
||||||
subgraph = graphviz.Graph(name='sg{}'.format(i))
|
subgraph = G(name='sg{}'.format(i))
|
||||||
subgraph.graph_attr['rank'] = 'same'
|
subgraph.graph_attr['rank'] = 'same'
|
||||||
|
subgraph.graph_attr['directed'] = 'true'
|
||||||
|
|
||||||
# Add a pseudonode for each device_set to enforce hierarchical layout
|
# Add a pseudonode for each device_set to enforce hierarchical layout
|
||||||
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
||||||
if i:
|
if i:
|
||||||
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||||
|
|
||||||
# Add each device to the graph
|
# Add each device to the graph
|
||||||
devices = []
|
devices = []
|
||||||
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
|
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
|
||||||
devices += Device.objects.filter(name__regex=query).select_related('device_role')
|
devices += Device.objects.filter(name__regex=query).select_related('device_role')
|
||||||
|
# Remove duplicate devices
|
||||||
|
devices = [d for d in devices if d.id not in seen]
|
||||||
|
seen.update([d.id for d in devices])
|
||||||
for d in devices:
|
for d in devices:
|
||||||
bg_color = '#{}'.format(d.device_role.color)
|
bg_color = '#{}'.format(d.device_role.color)
|
||||||
fg_color = '#{}'.format(foreground_color(d.device_role.color))
|
fg_color = '#{}'.format(foreground_color(d.device_role.color))
|
||||||
@@ -296,31 +349,64 @@ class TopologyMap(models.Model):
|
|||||||
for j in range(0, len(devices) - 1):
|
for j in range(0, len(devices) - 1):
|
||||||
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
||||||
|
|
||||||
graph.subgraph(subgraph)
|
self.graph.subgraph(subgraph)
|
||||||
|
|
||||||
# Compile list of all devices
|
# Compile list of all devices
|
||||||
device_superset = Q()
|
device_superset = Q()
|
||||||
for device_set in self.device_sets:
|
for device_set in self.device_sets:
|
||||||
for query in device_set.split(';'): # Split regexes on semicolons
|
for query in device_set.split(';'): # Split regexes on semicolons
|
||||||
device_superset = device_superset | Q(name__regex=query)
|
device_superset = device_superset | Q(name__regex=query)
|
||||||
|
devices = Device.objects.filter(*(device_superset,))
|
||||||
|
|
||||||
|
# Draw edges depending on graph type
|
||||||
|
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
|
||||||
|
self.add_network_connections(devices)
|
||||||
|
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
|
||||||
|
self.add_console_connections(devices)
|
||||||
|
elif self.type == TOPOLOGYMAP_TYPE_POWER:
|
||||||
|
self.add_power_connections(devices)
|
||||||
|
|
||||||
|
return self.graph.pipe(format=img_format)
|
||||||
|
|
||||||
|
def add_network_connections(self, devices):
|
||||||
|
|
||||||
|
from circuits.models import CircuitTermination
|
||||||
|
from dcim.models import InterfaceConnection
|
||||||
|
|
||||||
# Add all interface connections to the graph
|
# Add all interface connections to the graph
|
||||||
devices = Device.objects.filter(*(device_superset,))
|
|
||||||
connections = InterfaceConnection.objects.filter(
|
connections = InterfaceConnection.objects.filter(
|
||||||
interface_a__device__in=devices, interface_b__device__in=devices
|
interface_a__device__in=devices, interface_b__device__in=devices
|
||||||
)
|
)
|
||||||
for c in connections:
|
for c in connections:
|
||||||
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
|
self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
|
||||||
|
|
||||||
# Add all circuits to the graph
|
# Add all circuits to the graph
|
||||||
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
|
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
|
||||||
peer_termination = termination.get_peer_termination()
|
peer_termination = termination.get_peer_termination()
|
||||||
if (peer_termination is not None and peer_termination.interface is not None and
|
if (peer_termination is not None and peer_termination.interface is not None and
|
||||||
peer_termination.interface.device in devices):
|
peer_termination.interface.device in devices):
|
||||||
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
|
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
|
||||||
|
|
||||||
return graph.pipe(format=img_format)
|
def add_console_connections(self, devices):
|
||||||
|
|
||||||
|
from dcim.models import ConsolePort
|
||||||
|
|
||||||
|
# Add all console connections to the graph
|
||||||
|
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
|
||||||
|
for cp in console_ports:
|
||||||
|
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||||
|
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
|
||||||
|
|
||||||
|
def add_power_connections(self, devices):
|
||||||
|
|
||||||
|
from dcim.models import PowerPort
|
||||||
|
|
||||||
|
# Add all power connections to the graph
|
||||||
|
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
|
||||||
|
for pp in power_ports:
|
||||||
|
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||||
|
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -388,6 +474,24 @@ class ImageAttachment(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Report results
|
||||||
|
#
|
||||||
|
|
||||||
|
class ReportResult(models.Model):
|
||||||
|
"""
|
||||||
|
This model stores the results from running a user-defined report.
|
||||||
|
"""
|
||||||
|
report = models.CharField(max_length=255, unique=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True)
|
||||||
|
failed = models.BooleanField()
|
||||||
|
data = JSONField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['report']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# User actions
|
# User actions
|
||||||
#
|
#
|
||||||
|
|||||||
189
netbox/extras/reports.py
Normal file
189
netbox/extras/reports.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import pkgutil
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING
|
||||||
|
from .models import ReportResult
|
||||||
|
|
||||||
|
|
||||||
|
def is_report(obj):
|
||||||
|
"""
|
||||||
|
Returns True if the given object is a Report.
|
||||||
|
"""
|
||||||
|
if obj in Report.__subclasses__():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_report(module_name, report_name):
|
||||||
|
"""
|
||||||
|
Return a specific report from within a module.
|
||||||
|
"""
|
||||||
|
module = importlib.import_module('reports.{}'.format(module_name))
|
||||||
|
report = getattr(module, report_name, None)
|
||||||
|
if report is None:
|
||||||
|
return None
|
||||||
|
return report()
|
||||||
|
|
||||||
|
|
||||||
|
def get_reports():
|
||||||
|
"""
|
||||||
|
Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
|
||||||
|
|
||||||
|
[
|
||||||
|
(module_name, (report, report, report, ...)),
|
||||||
|
(module_name, (report, report, report, ...)),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
module_list = []
|
||||||
|
|
||||||
|
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||||
|
# defined.
|
||||||
|
for importer, module_name, is_pkg in pkgutil.walk_packages([settings.REPORTS_ROOT]):
|
||||||
|
module = importlib.import_module('reports.{}'.format(module_name))
|
||||||
|
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
|
||||||
|
module_list.append((module_name, report_list))
|
||||||
|
|
||||||
|
return module_list
|
||||||
|
|
||||||
|
|
||||||
|
class Report(object):
|
||||||
|
"""
|
||||||
|
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
|
||||||
|
report must have one or more test methods named `test_*`.
|
||||||
|
|
||||||
|
The `_results` attribute of a completed report will take the following form:
|
||||||
|
|
||||||
|
{
|
||||||
|
'test_bar': {
|
||||||
|
'failures': 42,
|
||||||
|
'log': [
|
||||||
|
(<datetime>, <level>, <object>, <message>),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'test_foo': {
|
||||||
|
'failures': 0,
|
||||||
|
'log': [
|
||||||
|
(<datetime>, <level>, <object>, <message>),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
description = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self._results = OrderedDict()
|
||||||
|
self.active_test = None
|
||||||
|
self.failed = False
|
||||||
|
|
||||||
|
# Compile test methods and initialize results skeleton
|
||||||
|
test_methods = []
|
||||||
|
for method in dir(self):
|
||||||
|
if method.startswith('test_') and callable(getattr(self, method)):
|
||||||
|
test_methods.append(method)
|
||||||
|
self._results[method] = OrderedDict([
|
||||||
|
('success', 0),
|
||||||
|
('info', 0),
|
||||||
|
('warning', 0),
|
||||||
|
('failure', 0),
|
||||||
|
('log', []),
|
||||||
|
])
|
||||||
|
if not test_methods:
|
||||||
|
raise Exception("A report must contain at least one test method.")
|
||||||
|
self.test_methods = test_methods
|
||||||
|
|
||||||
|
@property
|
||||||
|
def module(self):
|
||||||
|
return self.__module__.rsplit('.', 1)[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return '.'.join([self.module, self.name])
|
||||||
|
|
||||||
|
def _log(self, obj, message, level=LOG_DEFAULT):
|
||||||
|
"""
|
||||||
|
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
|
||||||
|
"""
|
||||||
|
if level not in LOG_LEVEL_CODES:
|
||||||
|
raise Exception("Unknown logging level: {}".format(level))
|
||||||
|
self._results[self.active_test]['log'].append((
|
||||||
|
timezone.now().isoformat(),
|
||||||
|
LOG_LEVEL_CODES.get(level),
|
||||||
|
str(obj) if obj else None,
|
||||||
|
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
|
||||||
|
message,
|
||||||
|
))
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
"""
|
||||||
|
Log a message which is not associated with a particular object.
|
||||||
|
"""
|
||||||
|
self._log(None, message, level=LOG_DEFAULT)
|
||||||
|
|
||||||
|
def log_success(self, obj, message=None):
|
||||||
|
"""
|
||||||
|
Record a successful test against an object. Logging a message is optional.
|
||||||
|
"""
|
||||||
|
if message:
|
||||||
|
self._log(obj, message, level=LOG_SUCCESS)
|
||||||
|
self._results[self.active_test]['success'] += 1
|
||||||
|
|
||||||
|
def log_info(self, obj, message):
|
||||||
|
"""
|
||||||
|
Log an informational message.
|
||||||
|
"""
|
||||||
|
self._log(obj, message, level=LOG_INFO)
|
||||||
|
self._results[self.active_test]['info'] += 1
|
||||||
|
|
||||||
|
def log_warning(self, obj, message):
|
||||||
|
"""
|
||||||
|
Log a warning.
|
||||||
|
"""
|
||||||
|
self._log(obj, message, level=LOG_WARNING)
|
||||||
|
self._results[self.active_test]['warning'] += 1
|
||||||
|
|
||||||
|
def log_failure(self, obj, message):
|
||||||
|
"""
|
||||||
|
Log a failure. Calling this method will automatically mark the report as failed.
|
||||||
|
"""
|
||||||
|
self._log(obj, message, level=LOG_FAILURE)
|
||||||
|
self._results[self.active_test]['failure'] += 1
|
||||||
|
self.failed = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Run the report and return its results. Each test method will be executed in order.
|
||||||
|
"""
|
||||||
|
for method_name in self.test_methods:
|
||||||
|
self.active_test = method_name
|
||||||
|
test_method = getattr(self, method_name)
|
||||||
|
test_method()
|
||||||
|
|
||||||
|
# Delete any previous ReportResult and create a new one to record the result.
|
||||||
|
ReportResult.objects.filter(report=self.full_name).delete()
|
||||||
|
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
|
||||||
|
result.save()
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
# Perform any post-run tasks
|
||||||
|
self.post_run()
|
||||||
|
|
||||||
|
def post_run(self):
|
||||||
|
"""
|
||||||
|
Extend this method to include any tasks which should execute after the report has been run.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ncclient import manager
|
|
||||||
import paramiko
|
import paramiko
|
||||||
import xmltodict
|
import xmltodict
|
||||||
|
from ncclient import manager
|
||||||
|
|
||||||
CONNECT_TIMEOUT = 5 # seconds
|
CONNECT_TIMEOUT = 5 # seconds
|
||||||
|
|
||||||
@@ -20,19 +20,6 @@ class RPCClient(object):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
|
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
|
||||||
|
|
||||||
def get_lldp_neighbors(self):
|
|
||||||
"""
|
|
||||||
Returns a list of dictionaries, each representing an LLDP neighbor adjacency.
|
|
||||||
|
|
||||||
{
|
|
||||||
'local-interface': <str>,
|
|
||||||
'name': <str>,
|
|
||||||
'remote-interface': <str>,
|
|
||||||
'chassis-id': <str>,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Feature not implemented for this platform.")
|
|
||||||
|
|
||||||
def get_inventory(self):
|
def get_inventory(self):
|
||||||
"""
|
"""
|
||||||
Returns a dictionary representing the device chassis and installed inventory items.
|
Returns a dictionary representing the device chassis and installed inventory items.
|
||||||
@@ -123,30 +110,6 @@ class JunosNC(RPCClient):
|
|||||||
# Close the connection to the device
|
# Close the connection to the device
|
||||||
self.manager.close_session()
|
self.manager.close_session()
|
||||||
|
|
||||||
def get_lldp_neighbors(self):
|
|
||||||
|
|
||||||
rpc_reply = self.manager.dispatch('get-lldp-neighbors-information')
|
|
||||||
lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information']
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for neighbor_raw in lldp_neighbors_raw:
|
|
||||||
neighbor = dict()
|
|
||||||
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
|
|
||||||
name = neighbor_raw.get('lldp-remote-system-name')
|
|
||||||
if name:
|
|
||||||
neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present
|
|
||||||
else:
|
|
||||||
neighbor['name'] = ''
|
|
||||||
try:
|
|
||||||
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
|
|
||||||
except KeyError:
|
|
||||||
# Older versions of Junos report on interface ID instead of description
|
|
||||||
neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id')
|
|
||||||
neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id')
|
|
||||||
result.append(neighbor)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_inventory(self):
|
def get_inventory(self):
|
||||||
|
|
||||||
def glean_items(node, depth=0):
|
def glean_items(node, depth=0):
|
||||||
@@ -200,8 +163,8 @@ class IOSSSH(SSHClient):
|
|||||||
|
|
||||||
sh_ver = self._send('show version').split('\r\n')
|
sh_ver = self._send('show version').split('\r\n')
|
||||||
return {
|
return {
|
||||||
'serial': parse(sh_ver, 'Processor board ID ([^\s]+)'),
|
'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'),
|
||||||
'description': parse(sh_ver, 'cisco ([^\s]+)')
|
'description': parse(sh_ver, r'cisco ([^\s]+)')
|
||||||
}
|
}
|
||||||
|
|
||||||
def items(chassis_serial=None):
|
def items(chassis_serial=None):
|
||||||
@@ -209,9 +172,9 @@ class IOSSSH(SSHClient):
|
|||||||
for i in cmd:
|
for i in cmd:
|
||||||
i_fmt = i.replace('\r\n', ' ')
|
i_fmt = i.replace('\r\n', ' ')
|
||||||
try:
|
try:
|
||||||
m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
|
m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1)
|
||||||
m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
|
m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1)
|
||||||
m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1)
|
m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1)
|
||||||
# Omit built-in items and those with no PID
|
# Omit built-in items and those with no PID
|
||||||
if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
|
if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
|
||||||
yield {
|
yield {
|
||||||
@@ -245,7 +208,7 @@ class OpengearSSH(SSHClient):
|
|||||||
try:
|
try:
|
||||||
stdin, stdout, stderr = self.ssh.exec_command("showserial")
|
stdin, stdout, stderr = self.ssh.exec_command("showserial")
|
||||||
serial = stdout.readlines()[0].strip()
|
serial = stdout.readlines()[0].strip()
|
||||||
except:
|
except Exception:
|
||||||
raise RuntimeError("Failed to glean chassis serial from device.")
|
raise RuntimeError("Failed to glean chassis serial from device.")
|
||||||
# Older models don't provide serial info
|
# Older models don't provide serial info
|
||||||
if serial == "No serial number information available":
|
if serial == "No serial number information available":
|
||||||
@@ -254,7 +217,7 @@ class OpengearSSH(SSHClient):
|
|||||||
try:
|
try:
|
||||||
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
|
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
|
||||||
description = stdout.readlines()[0].split(' ', 1)[1].strip()
|
description = stdout.readlines()[0].split(' ', 1)[1].strip()
|
||||||
except:
|
except Exception:
|
||||||
raise RuntimeError("Failed to glean chassis description from device.")
|
raise RuntimeError("Failed to glean chassis description from device.")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from extras.models import Graph, GRAPH_TYPE_SITE, ExportTemplate
|
from extras.constants import GRAPH_TYPE_SITE
|
||||||
|
from extras.models import Graph, ExportTemplate
|
||||||
from users.models import Token
|
from users.models import Token
|
||||||
from utilities.tests import HttpStatusMixin
|
from utilities.tests import HttpStatusMixin
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:graph-list')
|
url = reverse('extras-api:graph-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Graph.objects.count(), 4)
|
self.assertEqual(Graph.objects.count(), 4)
|
||||||
@@ -63,6 +63,35 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(graph4.name, data['name'])
|
self.assertEqual(graph4.name, data['name'])
|
||||||
self.assertEqual(graph4.source, data['source'])
|
self.assertEqual(graph4.source, data['source'])
|
||||||
|
|
||||||
|
def test_create_graph_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'type': GRAPH_TYPE_SITE,
|
||||||
|
'name': 'Test Graph 4',
|
||||||
|
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': GRAPH_TYPE_SITE,
|
||||||
|
'name': 'Test Graph 5',
|
||||||
|
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': GRAPH_TYPE_SITE,
|
||||||
|
'name': 'Test Graph 6',
|
||||||
|
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('extras-api:graph-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Graph.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_graph(self):
|
def test_update_graph(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -72,7 +101,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Graph.objects.count(), 3)
|
self.assertEqual(Graph.objects.count(), 3)
|
||||||
@@ -135,7 +164,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:exporttemplate-list')
|
url = reverse('extras-api:exporttemplate-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||||
@@ -144,6 +173,35 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(exporttemplate4.name, data['name'])
|
self.assertEqual(exporttemplate4.name, data['name'])
|
||||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||||
|
|
||||||
|
def test_create_exporttemplate_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'content_type': self.content_type.pk,
|
||||||
|
'name': 'Test Export Template 4',
|
||||||
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'content_type': self.content_type.pk,
|
||||||
|
'name': 'Test Export Template 5',
|
||||||
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'content_type': self.content_type.pk,
|
||||||
|
'name': 'Test Export Template 6',
|
||||||
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('extras-api:exporttemplate-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(ExportTemplate.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_exporttemplate(self):
|
def test_update_exporttemplate(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -153,7 +211,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(ExportTemplate.objects.count(), 3)
|
self.assertEqual(ExportTemplate.objects.count(), 3)
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from rest_framework import status
|
from datetime import date
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.models import (
|
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
|
||||||
CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
|
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
|
||||||
CF_TYPE_SELECT, CF_TYPE_URL,
|
|
||||||
)
|
|
||||||
from users.models import Token
|
from users.models import Token
|
||||||
from utilities.tests import HttpStatusMixin
|
from utilities.tests import HttpStatusMixin
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.conf.urls import url
|
|||||||
|
|
||||||
from extras import views
|
from extras import views
|
||||||
|
|
||||||
|
|
||||||
app_name = 'extras'
|
app_name = 'extras'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
||||||
@@ -12,4 +11,9 @@ urlpatterns = [
|
|||||||
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||||
url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||||
|
|
||||||
|
# Reports
|
||||||
|
url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
|
||||||
|
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
|
||||||
|
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.shortcuts import get_object_or_404
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.views.generic import View
|
||||||
|
|
||||||
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.views import ObjectDeleteView, ObjectEditView
|
from utilities.views import ObjectDeleteView, ObjectEditView
|
||||||
from .forms import ImageAttachmentForm
|
from .forms import ImageAttachmentForm
|
||||||
from .models import ImageAttachment
|
from .models import ImageAttachment, ReportResult, UserAction
|
||||||
|
from .reports import get_report, get_reports
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Image attachments
|
||||||
|
#
|
||||||
|
|
||||||
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'extras.change_imageattachment'
|
permission_required = 'extras.change_imageattachment'
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
form_class = ImageAttachmentForm
|
model_form = ImageAttachmentForm
|
||||||
|
|
||||||
def alter_obj(self, imageattachment, request, args, kwargs):
|
def alter_obj(self, imageattachment, request, args, kwargs):
|
||||||
if not imageattachment.pk:
|
if not imageattachment.pk:
|
||||||
@@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
|||||||
|
|
||||||
def get_return_url(self, request, imageattachment):
|
def get_return_url(self, request, imageattachment):
|
||||||
return imageattachment.parent.get_absolute_url()
|
return imageattachment.parent.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reports
|
||||||
|
#
|
||||||
|
|
||||||
|
class ReportListView(View):
|
||||||
|
"""
|
||||||
|
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
|
||||||
|
reports = get_reports()
|
||||||
|
results = {r.report: r for r in ReportResult.objects.all()}
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
for module, report_list in reports:
|
||||||
|
module_reports = []
|
||||||
|
for report in report_list:
|
||||||
|
report.result = results.get(report.full_name, None)
|
||||||
|
module_reports.append(report)
|
||||||
|
ret.append((module, module_reports))
|
||||||
|
|
||||||
|
return render(request, 'extras/report_list.html', {
|
||||||
|
'reports': ret,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ReportView(View):
|
||||||
|
"""
|
||||||
|
Display a single Report and its associated ReportResult (if any).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request, name):
|
||||||
|
|
||||||
|
# Retrieve the Report by "<module>.<report>"
|
||||||
|
module_name, report_name = name.split('.')
|
||||||
|
report = get_report(module_name, report_name)
|
||||||
|
if report is None:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
# Attach the ReportResult (if any)
|
||||||
|
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||||
|
|
||||||
|
return render(request, 'extras/report.html', {
|
||||||
|
'report': report,
|
||||||
|
'run_form': ConfirmationForm(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRunView(PermissionRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
Run a Report and record a new ReportResult.
|
||||||
|
"""
|
||||||
|
permission_required = 'extras.add_reportresult'
|
||||||
|
|
||||||
|
def post(self, request, name):
|
||||||
|
|
||||||
|
# Retrieve the Report by "<module>.<report>"
|
||||||
|
module_name, report_name = name.split('.')
|
||||||
|
report = get_report(module_name, report_name)
|
||||||
|
if report is None:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
form = ConfirmationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
|
||||||
|
# Run the Report. A new ReportResult is created.
|
||||||
|
report.run()
|
||||||
|
result = 'failed' if report.failed else 'passed'
|
||||||
|
msg = "Ran report {} ({})".format(report.full_name, result)
|
||||||
|
messages.success(request, mark_safe(msg))
|
||||||
|
UserAction.objects.log_create(request.user, report.result, msg)
|
||||||
|
|
||||||
|
return redirect('extras:report', name=report.full_name)
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
from rest_framework.validators import UniqueTogetherValidator
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
||||||
|
from dcim.models import Interface
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from ipam.models import (
|
from ipam.constants import (
|
||||||
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix,
|
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
|
||||||
PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
|
|
||||||
)
|
)
|
||||||
|
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||||
|
from virtualization.api.serializers import NestedVirtualMachineSerializer
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -23,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VRF
|
model = VRF
|
||||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NestedVRFSerializer(serializers.ModelSerializer):
|
class NestedVRFSerializer(serializers.ModelSerializer):
|
||||||
@@ -38,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VRF
|
model = VRF
|
||||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -88,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NestedAggregateSerializer(serializers.ModelSerializer):
|
class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||||
@@ -103,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -163,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer):
|
|||||||
model = VLAN
|
model = VLAN
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLAN
|
model = VLAN
|
||||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
@@ -213,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
|
|||||||
model = Prefix
|
model = Prefix
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -231,26 +245,60 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
|
|||||||
model = Prefix
|
model = Prefix
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AvailablePrefixSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
if self.context.get('vrf'):
|
||||||
|
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
|
||||||
|
else:
|
||||||
|
vrf = None
|
||||||
|
return OrderedDict([
|
||||||
|
('family', instance.version),
|
||||||
|
('prefix', str(instance)),
|
||||||
|
('vrf', vrf),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# IP addresses
|
# IP addresses
|
||||||
#
|
#
|
||||||
|
|
||||||
|
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
|
||||||
|
device = NestedDeviceSerializer()
|
||||||
|
virtual_machine = NestedVirtualMachineSerializer()
|
||||||
|
|
||||||
|
class Meta(InterfaceSerializer.Meta):
|
||||||
|
model = Interface
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'device', 'virtual_machine', 'name',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_url(self, obj):
|
||||||
|
"""
|
||||||
|
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
|
||||||
|
if the parent is a VirtualMachine.
|
||||||
|
"""
|
||||||
|
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
|
||||||
|
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
|
||||||
|
|
||||||
|
|
||||||
class IPAddressSerializer(CustomFieldModelSerializer):
|
class IPAddressSerializer(CustomFieldModelSerializer):
|
||||||
vrf = NestedVRFSerializer()
|
vrf = NestedVRFSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
|
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
|
||||||
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
|
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
|
||||||
interface = InterfaceSerializer()
|
interface = IPAddressInterfaceSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||||
'nat_outside', 'custom_fields',
|
'nat_outside', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -261,6 +309,7 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
|
|||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = ['id', 'url', 'family', 'address']
|
fields = ['id', 'url', 'family', 'address']
|
||||||
|
|
||||||
|
|
||||||
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
|
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
|
||||||
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
|
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
|
||||||
|
|
||||||
@@ -271,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
|
|||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -295,12 +344,16 @@ class AvailableIPSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class ServiceSerializer(serializers.ModelSerializer):
|
class ServiceSerializer(serializers.ModelSerializer):
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
|
virtual_machine = NestedVirtualMachineSerializer()
|
||||||
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
|
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
|
||||||
ipaddresses = NestedIPAddressSerializer(many=True)
|
ipaddresses = NestedIPAddressSerializer(many=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
fields = [
|
||||||
|
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||||
@@ -308,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
fields = [
|
||||||
|
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class IPAMRootView(routers.APIRootView):
|
|||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.APIRootView = IPAMRootView
|
router.APIRootView = IPAMRootView
|
||||||
|
|
||||||
|
# Field choices
|
||||||
|
router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice')
|
||||||
|
|
||||||
# VRFs
|
# VRFs
|
||||||
router.register(r'vrfs', views.VRFViewSet)
|
router.register(r'vrfs', views.VRFViewSet)
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.decorators import detail_route
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import detail_route
|
||||||
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
|
||||||
from ipam import filters
|
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from utilities.api import WritableSerializerMixin
|
from ipam import filters
|
||||||
|
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||||
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Field choices
|
||||||
|
#
|
||||||
|
|
||||||
|
class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||||
|
fields = (
|
||||||
|
(Aggregate, ['family']),
|
||||||
|
(Prefix, ['family', 'status']),
|
||||||
|
(IPAddress, ['family', 'status', 'role']),
|
||||||
|
(VLAN, ['status']),
|
||||||
|
(Service, ['protocol']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# VRFs
|
# VRFs
|
||||||
#
|
#
|
||||||
|
|
||||||
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class VRFViewSet(CustomFieldModelViewSet):
|
||||||
queryset = VRF.objects.select_related('tenant')
|
queryset = VRF.objects.select_related('tenant')
|
||||||
serializer_class = serializers.VRFSerializer
|
serializer_class = serializers.VRFSerializer
|
||||||
write_serializer_class = serializers.WritableVRFSerializer
|
write_serializer_class = serializers.WritableVRFSerializer
|
||||||
@@ -41,7 +53,7 @@ class RIRViewSet(ModelViewSet):
|
|||||||
# Aggregates
|
# Aggregates
|
||||||
#
|
#
|
||||||
|
|
||||||
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class AggregateViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Aggregate.objects.select_related('rir')
|
queryset = Aggregate.objects.select_related('rir')
|
||||||
serializer_class = serializers.AggregateSerializer
|
serializer_class = serializers.AggregateSerializer
|
||||||
write_serializer_class = serializers.WritableAggregateSerializer
|
write_serializer_class = serializers.WritableAggregateSerializer
|
||||||
@@ -62,12 +74,96 @@ class RoleViewSet(ModelViewSet):
|
|||||||
# Prefixes
|
# Prefixes
|
||||||
#
|
#
|
||||||
|
|
||||||
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class PrefixViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||||
serializer_class = serializers.PrefixSerializer
|
serializer_class = serializers.PrefixSerializer
|
||||||
write_serializer_class = serializers.WritablePrefixSerializer
|
write_serializer_class = serializers.WritablePrefixSerializer
|
||||||
filter_class = filters.PrefixFilter
|
filter_class = filters.PrefixFilter
|
||||||
|
|
||||||
|
@detail_route(url_path='available-prefixes', methods=['get', 'post'])
|
||||||
|
def available_prefixes(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
A convenience method for returning available child prefixes within a parent.
|
||||||
|
"""
|
||||||
|
prefix = get_object_or_404(Prefix, pk=pk)
|
||||||
|
available_prefixes = prefix.get_available_prefixes()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
|
if not request.user.has_perm('ipam.add_prefix'):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
# Normalize to a list of objects
|
||||||
|
requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
|
||||||
|
|
||||||
|
# Allocate prefixes to the requested objects based on availability within the parent
|
||||||
|
for i, requested_prefix in enumerate(requested_prefixes):
|
||||||
|
|
||||||
|
# Validate requested prefix size
|
||||||
|
error_msg = None
|
||||||
|
if 'prefix_length' not in requested_prefix:
|
||||||
|
error_msg = "Item {}: prefix_length field missing".format(i)
|
||||||
|
elif not isinstance(requested_prefix['prefix_length'], int):
|
||||||
|
error_msg = "Item {}: Invalid prefix length ({})".format(
|
||||||
|
i, requested_prefix['prefix_length']
|
||||||
|
)
|
||||||
|
elif prefix.family == 4 and requested_prefix['prefix_length'] > 32:
|
||||||
|
error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format(
|
||||||
|
i, requested_prefix['prefix_length']
|
||||||
|
)
|
||||||
|
elif prefix.family == 6 and requested_prefix['prefix_length'] > 128:
|
||||||
|
error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format(
|
||||||
|
i, requested_prefix['prefix_length']
|
||||||
|
)
|
||||||
|
if error_msg:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": error_msg
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the first available prefix equal to or larger than the requested size
|
||||||
|
for available_prefix in available_prefixes.iter_cidrs():
|
||||||
|
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
||||||
|
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
||||||
|
requested_prefix['prefix'] = allocated_prefix
|
||||||
|
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the allocated prefix from the list of available prefixes
|
||||||
|
available_prefixes.remove(allocated_prefix)
|
||||||
|
|
||||||
|
# Initialize the serializer with a list or a single object depending on what was requested
|
||||||
|
if isinstance(request.data, list):
|
||||||
|
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
|
||||||
|
else:
|
||||||
|
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
|
||||||
|
|
||||||
|
# Create the new Prefix(es)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
||||||
|
'request': request,
|
||||||
|
'vrf': prefix.vrf,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
@detail_route(url_path='available-ips', methods=['get', 'post'])
|
@detail_route(url_path='available-ips', methods=['get', 'post'])
|
||||||
def available_ips(self, request, pk=None):
|
def available_ips(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
@@ -84,28 +180,40 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
if not request.user.has_perm('ipam.add_ipaddress'):
|
if not request.user.has_perm('ipam.add_ipaddress'):
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
# Find the first available IP address in the prefix
|
# Normalize to a list of objects
|
||||||
try:
|
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||||
ipaddress = list(prefix.get_available_ips())[0]
|
|
||||||
except IndexError:
|
# Determine if the requested number of IPs is available
|
||||||
|
available_ips = prefix.get_available_ips()
|
||||||
|
if available_ips.size < len(requested_ips):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": "There are no available IPs within this prefix ({})".format(prefix)
|
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
|
||||||
|
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
|
||||||
},
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the new IP address
|
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
|
||||||
data = request.data.copy()
|
available_ips = iter(available_ips)
|
||||||
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
|
for requested_ip in requested_ips:
|
||||||
data['vrf'] = prefix.vrf
|
requested_ip['address'] = next(available_ips)
|
||||||
serializer = serializers.WritableIPAddressSerializer(data=data)
|
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||||
|
|
||||||
|
# Initialize the serializer with a list or a single object depending on what was requested
|
||||||
|
if isinstance(request.data, list):
|
||||||
|
serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
|
||||||
|
else:
|
||||||
|
serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
|
||||||
|
|
||||||
|
# Create the new IP address(es)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Determine the maximum amount of IPs to return
|
# Determine the maximum number of IPs to return
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
||||||
@@ -133,8 +241,12 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# IP addresses
|
# IP addresses
|
||||||
#
|
#
|
||||||
|
|
||||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
queryset = IPAddress.objects.select_related(
|
||||||
|
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
|
||||||
|
).prefetch_related(
|
||||||
|
'nat_outside'
|
||||||
|
)
|
||||||
serializer_class = serializers.IPAddressSerializer
|
serializer_class = serializers.IPAddressSerializer
|
||||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||||
filter_class = filters.IPAddressFilter
|
filter_class = filters.IPAddressFilter
|
||||||
@@ -144,7 +256,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# VLAN groups
|
# VLAN groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
class VLANGroupViewSet(ModelViewSet):
|
||||||
queryset = VLANGroup.objects.select_related('site')
|
queryset = VLANGroup.objects.select_related('site')
|
||||||
serializer_class = serializers.VLANGroupSerializer
|
serializer_class = serializers.VLANGroupSerializer
|
||||||
write_serializer_class = serializers.WritableVLANGroupSerializer
|
write_serializer_class = serializers.WritableVLANGroupSerializer
|
||||||
@@ -155,7 +267,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# VLANs
|
# VLANs
|
||||||
#
|
#
|
||||||
|
|
||||||
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class VLANViewSet(CustomFieldModelViewSet):
|
||||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||||
serializer_class = serializers.VLANSerializer
|
serializer_class = serializers.VLANSerializer
|
||||||
write_serializer_class = serializers.WritableVLANSerializer
|
write_serializer_class = serializers.WritableVLANSerializer
|
||||||
@@ -166,7 +278,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Services
|
# Services
|
||||||
#
|
#
|
||||||
|
|
||||||
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
|
class ServiceViewSet(ModelViewSet):
|
||||||
queryset = Service.objects.select_related('device')
|
queryset = Service.objects.select_related('device')
|
||||||
serializer_class = serializers.ServiceSerializer
|
serializer_class = serializers.ServiceSerializer
|
||||||
write_serializer_class = serializers.WritableServiceSerializer
|
write_serializer_class = serializers.WritableServiceSerializer
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ IPADDRESS_ROLE_VIP = 40
|
|||||||
IPADDRESS_ROLE_VRRP = 41
|
IPADDRESS_ROLE_VRRP = 41
|
||||||
IPADDRESS_ROLE_HSRP = 42
|
IPADDRESS_ROLE_HSRP = 42
|
||||||
IPADDRESS_ROLE_GLBP = 43
|
IPADDRESS_ROLE_GLBP = 43
|
||||||
|
IPADDRESS_ROLE_CARP = 44
|
||||||
IPADDRESS_ROLE_CHOICES = (
|
IPADDRESS_ROLE_CHOICES = (
|
||||||
(IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
|
(IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
|
||||||
(IPADDRESS_ROLE_SECONDARY, 'Secondary'),
|
(IPADDRESS_ROLE_SECONDARY, 'Secondary'),
|
||||||
@@ -47,6 +48,7 @@ IPADDRESS_ROLE_CHOICES = (
|
|||||||
(IPADDRESS_ROLE_VRRP, 'VRRP'),
|
(IPADDRESS_ROLE_VRRP, 'VRRP'),
|
||||||
(IPADDRESS_ROLE_HSRP, 'HSRP'),
|
(IPADDRESS_ROLE_HSRP, 'HSRP'),
|
||||||
(IPADDRESS_ROLE_GLBP, 'GLBP'),
|
(IPADDRESS_ROLE_GLBP, 'GLBP'),
|
||||||
|
(IPADDRESS_ROLE_CARP, 'CARP'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# VLAN statuses
|
# VLAN statuses
|
||||||
@@ -59,7 +61,7 @@ VLAN_STATUS_CHOICES = (
|
|||||||
(VLAN_STATUS_DEPRECATED, 'Deprecated')
|
(VLAN_STATUS_DEPRECATED, 'Deprecated')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bootstrap CSS classes for various statuses
|
# Bootstrap CSS classes
|
||||||
STATUS_CHOICE_CLASSES = {
|
STATUS_CHOICE_CLASSES = {
|
||||||
0: 'default',
|
0: 'default',
|
||||||
1: 'primary',
|
1: 'primary',
|
||||||
@@ -68,6 +70,16 @@ STATUS_CHOICE_CLASSES = {
|
|||||||
4: 'warning',
|
4: 'warning',
|
||||||
5: 'success',
|
5: 'success',
|
||||||
}
|
}
|
||||||
|
ROLE_CHOICE_CLASSES = {
|
||||||
|
10: 'default',
|
||||||
|
20: 'primary',
|
||||||
|
30: 'warning',
|
||||||
|
40: 'success',
|
||||||
|
41: 'success',
|
||||||
|
42: 'success',
|
||||||
|
43: 'success',
|
||||||
|
44: 'success',
|
||||||
|
}
|
||||||
|
|
||||||
# IP protocols (for services)
|
# IP protocols (for services)
|
||||||
IP_PROTOCOL_TCP = 6
|
IP_PROTOCOL_TCP = 6
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from netaddr import IPNetwork
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from netaddr import AddrFormatError, IPNetwork
|
||||||
|
|
||||||
from .formfields import IPFormField
|
from .formfields import IPFormField
|
||||||
from .lookups import (
|
from . import lookups
|
||||||
EndsWith, IEndsWith, IRegex, IStartsWith, NetContained, NetContainedOrEqual, NetContains, NetContainsOrEquals,
|
|
||||||
NetHost, NetHostContained, NetMaskLength, Regex, StartsWith,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prefix_validator(prefix):
|
def prefix_validator(prefix):
|
||||||
@@ -30,7 +26,9 @@ class BaseIPField(models.Field):
|
|||||||
return value
|
return value
|
||||||
try:
|
try:
|
||||||
return IPNetwork(value)
|
return IPNetwork(value)
|
||||||
except ValueError as e:
|
except AddrFormatError as e:
|
||||||
|
raise ValidationError("Invalid IP address format: {}".format(value))
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
raise ValidationError(e)
|
raise ValidationError(e)
|
||||||
|
|
||||||
def get_prep_value(self, value):
|
def get_prep_value(self, value):
|
||||||
@@ -58,17 +56,18 @@ class IPNetworkField(BaseIPField):
|
|||||||
return 'cidr'
|
return 'cidr'
|
||||||
|
|
||||||
|
|
||||||
IPNetworkField.register_lookup(EndsWith)
|
IPNetworkField.register_lookup(lookups.IExact)
|
||||||
IPNetworkField.register_lookup(IEndsWith)
|
IPNetworkField.register_lookup(lookups.EndsWith)
|
||||||
IPNetworkField.register_lookup(StartsWith)
|
IPNetworkField.register_lookup(lookups.IEndsWith)
|
||||||
IPNetworkField.register_lookup(IStartsWith)
|
IPNetworkField.register_lookup(lookups.StartsWith)
|
||||||
IPNetworkField.register_lookup(Regex)
|
IPNetworkField.register_lookup(lookups.IStartsWith)
|
||||||
IPNetworkField.register_lookup(IRegex)
|
IPNetworkField.register_lookup(lookups.Regex)
|
||||||
IPNetworkField.register_lookup(NetContained)
|
IPNetworkField.register_lookup(lookups.IRegex)
|
||||||
IPNetworkField.register_lookup(NetContainedOrEqual)
|
IPNetworkField.register_lookup(lookups.NetContained)
|
||||||
IPNetworkField.register_lookup(NetContains)
|
IPNetworkField.register_lookup(lookups.NetContainedOrEqual)
|
||||||
IPNetworkField.register_lookup(NetContainsOrEquals)
|
IPNetworkField.register_lookup(lookups.NetContains)
|
||||||
IPNetworkField.register_lookup(NetMaskLength)
|
IPNetworkField.register_lookup(lookups.NetContainsOrEquals)
|
||||||
|
IPNetworkField.register_lookup(lookups.NetMaskLength)
|
||||||
|
|
||||||
|
|
||||||
class IPAddressField(BaseIPField):
|
class IPAddressField(BaseIPField):
|
||||||
@@ -81,16 +80,17 @@ class IPAddressField(BaseIPField):
|
|||||||
return 'inet'
|
return 'inet'
|
||||||
|
|
||||||
|
|
||||||
IPAddressField.register_lookup(EndsWith)
|
IPAddressField.register_lookup(lookups.IExact)
|
||||||
IPAddressField.register_lookup(IEndsWith)
|
IPAddressField.register_lookup(lookups.EndsWith)
|
||||||
IPAddressField.register_lookup(StartsWith)
|
IPAddressField.register_lookup(lookups.IEndsWith)
|
||||||
IPAddressField.register_lookup(IStartsWith)
|
IPAddressField.register_lookup(lookups.StartsWith)
|
||||||
IPAddressField.register_lookup(Regex)
|
IPAddressField.register_lookup(lookups.IStartsWith)
|
||||||
IPAddressField.register_lookup(IRegex)
|
IPAddressField.register_lookup(lookups.Regex)
|
||||||
IPAddressField.register_lookup(NetContained)
|
IPAddressField.register_lookup(lookups.IRegex)
|
||||||
IPAddressField.register_lookup(NetContainedOrEqual)
|
IPAddressField.register_lookup(lookups.NetContained)
|
||||||
IPAddressField.register_lookup(NetContains)
|
IPAddressField.register_lookup(lookups.NetContainedOrEqual)
|
||||||
IPAddressField.register_lookup(NetContainsOrEquals)
|
IPAddressField.register_lookup(lookups.NetContains)
|
||||||
IPAddressField.register_lookup(NetHost)
|
IPAddressField.register_lookup(lookups.NetContainsOrEquals)
|
||||||
IPAddressField.register_lookup(NetHostContained)
|
IPAddressField.register_lookup(lookups.NetHost)
|
||||||
IPAddressField.register_lookup(NetMaskLength)
|
IPAddressField.register_lookup(lookups.NetHostContained)
|
||||||
|
IPAddressField.register_lookup(lookups.NetMaskLength)
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from netaddr import IPNetwork
|
from django.core.exceptions import ValidationError
|
||||||
from netaddr.core import AddrFormatError
|
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
import netaddr
|
||||||
|
from netaddr.core import AddrFormatError
|
||||||
|
|
||||||
from dcim.models import Site, Device, Interface
|
from dcim.models import Site, Device, Interface
|
||||||
from extras.filters import CustomFieldFilterSet
|
from extras.filters import CustomFieldFilterSet
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
from utilities.filters import NumericInFilter
|
||||||
from .models import (
|
from virtualization.models import VirtualMachine
|
||||||
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
|
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
||||||
Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
|
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||||
@@ -22,12 +21,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
@@ -81,7 +80,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
qs_filter = Q(description__icontains=value)
|
qs_filter = Q(description__icontains=value)
|
||||||
try:
|
try:
|
||||||
prefix = str(IPNetwork(value.strip()).cidr)
|
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -101,45 +100,53 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
parent = django_filters.CharFilter(
|
within = django_filters.CharFilter(
|
||||||
method='search_by_parent',
|
method='search_within',
|
||||||
label='Parent prefix',
|
label='Within prefix',
|
||||||
|
)
|
||||||
|
within_include = django_filters.CharFilter(
|
||||||
|
method='search_within_include',
|
||||||
|
label='Within and including prefix',
|
||||||
|
)
|
||||||
|
contains = django_filters.CharFilter(
|
||||||
|
method='search_contains',
|
||||||
|
label='Prefixes which contain this prefix or IP',
|
||||||
)
|
)
|
||||||
mask_length = django_filters.NumberFilter(
|
mask_length = django_filters.NumberFilter(
|
||||||
method='filter_mask_length',
|
method='filter_mask_length',
|
||||||
label='Mask length',
|
label='Mask length',
|
||||||
)
|
)
|
||||||
vrf_id = NullableModelMultipleChoiceFilter(
|
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
label='VRF',
|
label='VRF',
|
||||||
)
|
)
|
||||||
vrf = NullableModelMultipleChoiceFilter(
|
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='vrf',
|
name='vrf__rd',
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label='VRF (RD)',
|
label='VRF (RD)',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
site_id = NullableModelMultipleChoiceFilter(
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
label='Site (ID)',
|
label='Site (ID)',
|
||||||
)
|
)
|
||||||
site = NullableModelMultipleChoiceFilter(
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='site',
|
name='site__slug',
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
)
|
)
|
||||||
vlan_id = NullableModelMultipleChoiceFilter(
|
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
label='VLAN (ID)',
|
label='VLAN (ID)',
|
||||||
)
|
)
|
||||||
@@ -147,18 +154,19 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
name='vlan__vid',
|
name='vlan__vid',
|
||||||
label='VLAN number (1-4095)',
|
label='VLAN number (1-4095)',
|
||||||
)
|
)
|
||||||
role_id = NullableModelMultipleChoiceFilter(
|
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
label='Role (ID)',
|
label='Role (ID)',
|
||||||
)
|
)
|
||||||
role = NullableModelMultipleChoiceFilter(
|
role = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='role',
|
name='role__slug',
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Role (slug)',
|
label='Role (slug)',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=PREFIX_STATUS_CHOICES
|
choices=PREFIX_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -170,22 +178,46 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
qs_filter = Q(description__icontains=value)
|
qs_filter = Q(description__icontains=value)
|
||||||
try:
|
try:
|
||||||
prefix = str(IPNetwork(value.strip()).cidr)
|
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
pass
|
pass
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
def search_by_parent(self, queryset, name, value):
|
def search_within(self, queryset, name, value):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
try:
|
try:
|
||||||
query = str(IPNetwork(value).cidr)
|
query = str(netaddr.IPNetwork(value).cidr)
|
||||||
|
return queryset.filter(prefix__net_contained=query)
|
||||||
|
except (AddrFormatError, ValueError):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
def search_within_include(self, queryset, name, value):
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
try:
|
||||||
|
query = str(netaddr.IPNetwork(value).cidr)
|
||||||
return queryset.filter(prefix__net_contained_or_equal=query)
|
return queryset.filter(prefix__net_contained_or_equal=query)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
def search_contains(self, queryset, name, value):
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
try:
|
||||||
|
# Searching by prefix
|
||||||
|
if '/' in value:
|
||||||
|
return queryset.filter(prefix__net_contains_or_equals=str(netaddr.IPNetwork(value).cidr))
|
||||||
|
# Searching by IP address
|
||||||
|
else:
|
||||||
|
return queryset.filter(prefix__net_contains=str(netaddr.IPAddress(value)))
|
||||||
|
except (AddrFormatError, ValueError):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
def filter_mask_length(self, queryset, name, value):
|
def filter_mask_length(self, queryset, name, value):
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
@@ -202,47 +234,62 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search_by_parent',
|
method='search_by_parent',
|
||||||
label='Parent prefix',
|
label='Parent prefix',
|
||||||
)
|
)
|
||||||
|
address = django_filters.CharFilter(
|
||||||
|
method='filter_address',
|
||||||
|
label='Address',
|
||||||
|
)
|
||||||
mask_length = django_filters.NumberFilter(
|
mask_length = django_filters.NumberFilter(
|
||||||
method='filter_mask_length',
|
method='filter_mask_length',
|
||||||
label='Mask length',
|
label='Mask length',
|
||||||
)
|
)
|
||||||
vrf_id = NullableModelMultipleChoiceFilter(
|
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
label='VRF',
|
label='VRF',
|
||||||
)
|
)
|
||||||
vrf = NullableModelMultipleChoiceFilter(
|
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='vrf',
|
name='vrf__rd',
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label='VRF (RD)',
|
label='VRF (RD)',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device = django_filters.CharFilter(
|
||||||
name='interface__device',
|
method='filter_device',
|
||||||
queryset=Device.objects.all(),
|
name='name',
|
||||||
|
label='Device',
|
||||||
|
)
|
||||||
|
device_id = django_filters.NumberFilter(
|
||||||
|
method='filter_device',
|
||||||
|
name='pk',
|
||||||
label='Device (ID)',
|
label='Device (ID)',
|
||||||
)
|
)
|
||||||
device = django_filters.ModelMultipleChoiceFilter(
|
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='interface__device__name',
|
name='interface__virtual_machine',
|
||||||
queryset=Device.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
label='Virtual machine (ID)',
|
||||||
|
)
|
||||||
|
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='interface__virtual_machine__name',
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
label='Device (name)',
|
label='Virtual machine (name)',
|
||||||
)
|
)
|
||||||
interface_id = django_filters.ModelMultipleChoiceFilter(
|
interface_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Interface.objects.all(),
|
queryset=Interface.objects.all(),
|
||||||
label='Interface (ID)',
|
label='Interface (ID)',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=IPADDRESS_STATUS_CHOICES
|
choices=IPADDRESS_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
)
|
)
|
||||||
role = django_filters.MultipleChoiceFilter(
|
role = django_filters.MultipleChoiceFilter(
|
||||||
choices=IPADDRESS_ROLE_CHOICES
|
choices=IPADDRESS_ROLE_CHOICES
|
||||||
@@ -255,12 +302,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
qs_filter = Q(description__icontains=value)
|
qs_filter = (
|
||||||
try:
|
Q(description__icontains=value) |
|
||||||
ipaddress = str(IPNetwork(value.strip()))
|
Q(address__istartswith=value)
|
||||||
qs_filter |= Q(address__net_host=ipaddress)
|
)
|
||||||
except (AddrFormatError, ValueError):
|
|
||||||
pass
|
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
def search_by_parent(self, queryset, name, value):
|
def search_by_parent(self, queryset, name, value):
|
||||||
@@ -268,24 +313,43 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
try:
|
try:
|
||||||
query = str(IPNetwork(value.strip()).cidr)
|
query = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||||
return queryset.filter(address__net_host_contained=query)
|
return queryset.filter(address__net_host_contained=query)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
def filter_address(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
try:
|
||||||
|
# Match address and subnet mask
|
||||||
|
if '/' in value:
|
||||||
|
return queryset.filter(address=value)
|
||||||
|
return queryset.filter(address__net_host=value)
|
||||||
|
except ValidationError:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
def filter_mask_length(self, queryset, name, value):
|
def filter_mask_length(self, queryset, name, value):
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(address__net_mask_length=value)
|
return queryset.filter(address__net_mask_length=value)
|
||||||
|
|
||||||
|
def filter_device(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||||
|
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||||
|
return queryset.filter(interface_id__in=vc_interface_ids)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class VLANGroupFilter(django_filters.FilterSet):
|
class VLANGroupFilter(django_filters.FilterSet):
|
||||||
site_id = NullableModelMultipleChoiceFilter(
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
label='Site (ID)',
|
label='Site (ID)',
|
||||||
)
|
)
|
||||||
site = NullableModelMultipleChoiceFilter(
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='site',
|
name='site__slug',
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
@@ -302,48 +366,49 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
site_id = NullableModelMultipleChoiceFilter(
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
label='Site (ID)',
|
label='Site (ID)',
|
||||||
)
|
)
|
||||||
site = NullableModelMultipleChoiceFilter(
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='site',
|
name='site__slug',
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
)
|
)
|
||||||
group_id = NullableModelMultipleChoiceFilter(
|
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
label='Group (ID)',
|
label='Group (ID)',
|
||||||
)
|
)
|
||||||
group = NullableModelMultipleChoiceFilter(
|
group = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='group',
|
name='group__slug',
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Group',
|
label='Group',
|
||||||
)
|
)
|
||||||
tenant_id = NullableModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
)
|
)
|
||||||
tenant = NullableModelMultipleChoiceFilter(
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='tenant',
|
name='tenant__slug',
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
role_id = NullableModelMultipleChoiceFilter(
|
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
label='Role (ID)',
|
label='Role (ID)',
|
||||||
)
|
)
|
||||||
role = NullableModelMultipleChoiceFilter(
|
role = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='role',
|
name='role__slug',
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Role (slug)',
|
label='Role (slug)',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=VLAN_STATUS_CHOICES
|
choices=VLAN_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -372,6 +437,16 @@ class ServiceFilter(django_filters.FilterSet):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
label='Device (name)',
|
label='Device (name)',
|
||||||
)
|
)
|
||||||
|
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
label='Virtual machine (ID)',
|
||||||
|
)
|
||||||
|
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='virtual_machine__name',
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label='Virtual machine (name)',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.0.255.1/32",
|
"address": "10.0.255.1/32",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 3,
|
"interface_id": 3,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "169.254.254.1/31",
|
"address": "169.254.254.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 4,
|
"interface_id": 4,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.0.255.2/32",
|
"address": "10.0.255.2/32",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 185,
|
"interface_id": 185,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "169.254.1.1/31",
|
"address": "169.254.1.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 213,
|
"interface_id": 213,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.0.254.1/24",
|
"address": "10.0.254.1/24",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 12,
|
"interface_id": 12,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.21.1/31",
|
"address": "10.15.21.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 218,
|
"interface_id": 218,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.21.2/31",
|
"address": "10.15.21.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 9,
|
"interface_id": 9,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.22.1/31",
|
"address": "10.15.22.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 8,
|
"interface_id": 8,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.20.1/31",
|
"address": "10.15.20.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 7,
|
"interface_id": 7,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.16.20.1/31",
|
"address": "10.16.20.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 216,
|
"interface_id": 216,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.22.2/31",
|
"address": "10.15.22.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 206,
|
"interface_id": 206,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.16.22.1/31",
|
"address": "10.16.22.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 217,
|
"interface_id": 217,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.16.22.2/31",
|
"address": "10.16.22.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 205,
|
"interface_id": 205,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.16.20.2/31",
|
"address": "10.16.20.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 211,
|
"interface_id": 211,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.15.22.2/31",
|
"address": "10.15.22.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 212,
|
"interface_id": 212,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "10.0.254.2/32",
|
"address": "10.0.254.2/32",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 188,
|
"interface_id": 188,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "169.254.1.1/31",
|
"address": "169.254.1.1/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 200,
|
"interface_id": 200,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
"family": 4,
|
"family": 4,
|
||||||
"address": "169.254.1.2/31",
|
"address": "169.254.1.2/31",
|
||||||
"vrf": null,
|
"vrf": null,
|
||||||
"interface": 194,
|
"interface_id": 194,
|
||||||
"nat_inside": null,
|
"nat_inside": null,
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from netaddr import IPNetwork, AddrFormatError
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from netaddr import IPNetwork, AddrFormatError
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,15 +9,13 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
|
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
|
||||||
add_blank_choice,
|
SlugField, add_blank_choice,
|
||||||
)
|
)
|
||||||
from .models import (
|
from virtualization.models import VirtualMachine
|
||||||
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
|
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
||||||
Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
IP_FAMILY_CHOICES = [
|
IP_FAMILY_CHOICES = [
|
||||||
('', 'All'),
|
('', 'All'),
|
||||||
@@ -25,11 +23,8 @@ IP_FAMILY_CHOICES = [
|
|||||||
(6, 'IPv6'),
|
(6, 'IPv6'),
|
||||||
]
|
]
|
||||||
|
|
||||||
PREFIX_MASK_LENGTH_CHOICES = [
|
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
|
||||||
('', '---------'),
|
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
|
||||||
] + [(i, i) for i in range(1, 128)]
|
|
||||||
|
|
||||||
IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -62,7 +57,7 @@ class VRFCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VRF
|
model = VRF
|
||||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
fields = VRF.csv_headers
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': 'VRF name',
|
'name': 'VRF name',
|
||||||
}
|
}
|
||||||
@@ -83,8 +78,11 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = VRF
|
model = VRF
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
|
tenant = FilterChoiceField(
|
||||||
null_option=(0, None))
|
queryset=Tenant.objects.annotate(filter_count=Count('vrfs')),
|
||||||
|
to_field_name='slug',
|
||||||
|
null_label='-- None --'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -99,6 +97,17 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = ['name', 'slug', 'is_private']
|
fields = ['name', 'slug', 'is_private']
|
||||||
|
|
||||||
|
|
||||||
|
class RIRCSVForm(forms.ModelForm):
|
||||||
|
slug = SlugField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RIR
|
||||||
|
fields = RIR.csv_headers
|
||||||
|
help_texts = {
|
||||||
|
'name': 'RIR name',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RIRFilterForm(BootstrapMixin, forms.Form):
|
class RIRFilterForm(BootstrapMixin, forms.Form):
|
||||||
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
|
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
|
||||||
('', '---------'),
|
('', '---------'),
|
||||||
@@ -135,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fields = ['prefix', 'rir', 'date_added', 'description']
|
fields = Aggregate.csv_headers
|
||||||
|
|
||||||
|
|
||||||
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||||
@@ -171,6 +180,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = ['name', 'slug']
|
fields = ['name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCSVForm(forms.ModelForm):
|
||||||
|
slug = SlugField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Role
|
||||||
|
fields = Role.csv_headers
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Role name',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Prefixes
|
# Prefixes
|
||||||
#
|
#
|
||||||
@@ -279,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Prefix
|
model = Prefix
|
||||||
fields = [
|
fields = Prefix.csv_headers
|
||||||
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
@@ -332,17 +350,10 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
||||||
|
|
||||||
|
|
||||||
def prefix_status_choices():
|
|
||||||
status_counts = {}
|
|
||||||
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
|
||||||
status_counts[status['status']] = status['count']
|
|
||||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
parent = forms.CharField(required=False, label='Parent prefix', widget=forms.TextInput(attrs={
|
within_include = forms.CharField(required=False, label='Search within', widget=forms.TextInput(attrs={
|
||||||
'placeholder': 'Prefix',
|
'placeholder': 'Prefix',
|
||||||
}))
|
}))
|
||||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
|
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
|
||||||
@@ -351,23 +362,28 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
|
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
|
||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label='VRF',
|
label='VRF',
|
||||||
null_option=(0, 'Global')
|
null_label='-- Global --'
|
||||||
)
|
)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
|
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
|
)
|
||||||
|
status = AnnotatedMultipleChoiceField(
|
||||||
|
choices=PREFIX_STATUS_CHOICES,
|
||||||
|
annotate=Prefix.objects.all(),
|
||||||
|
annotate_field='status',
|
||||||
|
required=False
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
|
|
||||||
site = FilterChoiceField(
|
site = FilterChoiceField(
|
||||||
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
role = FilterChoiceField(
|
role = FilterChoiceField(
|
||||||
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
|
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
|
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
|
||||||
|
|
||||||
@@ -377,50 +393,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
|
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
|
||||||
interface_site = forms.ModelChoiceField(
|
interface = forms.ModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
|
||||||
required=False,
|
|
||||||
label='Site',
|
|
||||||
widget=forms.Select(
|
|
||||||
attrs={'filter-for': 'interface_rack'}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
interface_rack = ChainedModelChoiceField(
|
|
||||||
queryset=Rack.objects.all(),
|
|
||||||
chains=(
|
|
||||||
('site', 'interface_site'),
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
label='Rack',
|
|
||||||
widget=APISelect(
|
|
||||||
api_url='/api/dcim/racks/?site_id={{interface_site}}',
|
|
||||||
display_field='display_name',
|
|
||||||
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
interface_device = ChainedModelChoiceField(
|
|
||||||
queryset=Device.objects.all(),
|
|
||||||
chains=(
|
|
||||||
('site', 'interface_site'),
|
|
||||||
('rack', 'interface_rack'),
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
label='Device',
|
|
||||||
widget=APISelect(
|
|
||||||
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
|
|
||||||
display_field='display_name',
|
|
||||||
attrs={'filter-for': 'interface'}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
interface = ChainedModelChoiceField(
|
|
||||||
queryset=Interface.objects.all(),
|
queryset=Interface.objects.all(),
|
||||||
chains=(
|
required=False
|
||||||
('device', 'interface_device'),
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
widget=APISelect(
|
|
||||||
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
nat_site = forms.ModelChoiceField(
|
nat_site = forms.ModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
@@ -479,13 +454,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
|||||||
obj_label='address'
|
obj_label='address'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
|
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
|
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
|
||||||
'nat_inside', 'tenant_group', 'tenant',
|
'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -493,10 +468,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
|||||||
# Initialize helper selectors
|
# Initialize helper selectors
|
||||||
instance = kwargs.get('instance')
|
instance = kwargs.get('instance')
|
||||||
initial = kwargs.get('initial', {}).copy()
|
initial = kwargs.get('initial', {}).copy()
|
||||||
if instance and instance.interface is not None:
|
|
||||||
initial['interface_site'] = instance.interface.device.site
|
|
||||||
initial['interface_rack'] = instance.interface.device.rack
|
|
||||||
initial['interface_device'] = instance.interface.device
|
|
||||||
if instance and instance.nat_inside and instance.nat_inside.device is not None:
|
if instance and instance.nat_inside and instance.nat_inside.device is not None:
|
||||||
initial['nat_site'] = instance.nat_inside.device.site
|
initial['nat_site'] = instance.nat_inside.device.site
|
||||||
initial['nat_rack'] = instance.nat_inside.device.rack
|
initial['nat_rack'] = instance.nat_inside.device.rack
|
||||||
@@ -507,54 +478,57 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
|||||||
|
|
||||||
self.fields['vrf'].empty_label = 'Global'
|
self.fields['vrf'].empty_label = 'Global'
|
||||||
|
|
||||||
# Initialize primary_for_device if IP address is already assigned
|
# Limit interface selections to those belonging to the parent device/VM
|
||||||
if self.instance.interface is not None:
|
if self.instance and self.instance.interface:
|
||||||
device = self.instance.interface.device
|
self.fields['interface'].queryset = Interface.objects.filter(
|
||||||
|
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.fields['interface'].choices = []
|
||||||
|
|
||||||
|
# Initialize primary_for_parent if IP address is already assigned
|
||||||
|
if self.instance.pk and self.instance.interface is not None:
|
||||||
|
parent = self.instance.interface.parent
|
||||||
if (
|
if (
|
||||||
self.instance.address.version == 4 and device.primary_ip4 == self.instance or
|
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
|
||||||
self.instance.address.version == 6 and device.primary_ip6 == self.instance
|
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
|
||||||
):
|
):
|
||||||
self.initial['primary_for_device'] = True
|
self.initial['primary_for_parent'] = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super(IPAddressForm, self).clean()
|
super(IPAddressForm, self).clean()
|
||||||
|
|
||||||
# Primary IP assignment is only available if an interface has been assigned.
|
# Primary IP assignment is only available if an interface has been assigned.
|
||||||
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
|
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
|
||||||
self.add_error(
|
self.add_error(
|
||||||
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
||||||
|
|
||||||
# Assign this IPAddress as the primary for the associated Device.
|
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||||
if self.cleaned_data['primary_for_device']:
|
if self.cleaned_data['primary_for_parent']:
|
||||||
device = self.cleaned_data['interface'].device
|
parent = self.cleaned_data['interface'].parent
|
||||||
if ipaddress.address.version == 4:
|
if ipaddress.address.version == 4:
|
||||||
device.primary_ip4 = ipaddress
|
parent.primary_ip4 = ipaddress
|
||||||
else:
|
else:
|
||||||
device.primary_ip6 = ipaddress
|
parent.primary_ip6 = ipaddress
|
||||||
device.save()
|
parent.save()
|
||||||
|
elif self.cleaned_data['interface']:
|
||||||
# Clear assignment as primary for device if set.
|
parent = self.cleaned_data['interface'].parent
|
||||||
else:
|
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
|
||||||
try:
|
parent.primary_ip4 = None
|
||||||
if ipaddress.address.version == 4:
|
parent.save()
|
||||||
device = ipaddress.primary_ip4_for
|
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
|
||||||
device.primary_ip4 = None
|
parent.primary_ip6 = None
|
||||||
else:
|
parent.save()
|
||||||
device = ipaddress.primary_ip6_for
|
|
||||||
device.primary_ip6 = None
|
|
||||||
device.save()
|
|
||||||
except Device.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ipaddress
|
return ipaddress
|
||||||
|
|
||||||
|
|
||||||
class IPAddressPatternForm(BootstrapMixin, forms.Form):
|
class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
|
||||||
pattern = ExpandableIPAddressField(label='Address pattern')
|
pattern = ExpandableIPAddressField(label='Address pattern')
|
||||||
|
|
||||||
|
|
||||||
@@ -606,6 +580,15 @@ class IPAddressCSVForm(forms.ModelForm):
|
|||||||
'invalid_choice': 'Device not found.',
|
'invalid_choice': 'Device not found.',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
virtual_machine = forms.ModelChoiceField(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Name of assigned virtual machine',
|
||||||
|
error_messages={
|
||||||
|
'invalid_choice': 'Virtual machine not found.',
|
||||||
|
}
|
||||||
|
)
|
||||||
interface_name = forms.CharField(
|
interface_name = forms.CharField(
|
||||||
help_text='Name of assigned interface',
|
help_text='Name of assigned interface',
|
||||||
required=False
|
required=False
|
||||||
@@ -617,30 +600,44 @@ class IPAddressCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description']
|
fields = IPAddress.csv_headers
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
super(IPAddressCSVForm, self).clean()
|
super(IPAddressCSVForm, self).clean()
|
||||||
|
|
||||||
device = self.cleaned_data.get('device')
|
device = self.cleaned_data.get('device')
|
||||||
|
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||||
interface_name = self.cleaned_data.get('interface_name')
|
interface_name = self.cleaned_data.get('interface_name')
|
||||||
is_primary = self.cleaned_data.get('is_primary')
|
is_primary = self.cleaned_data.get('is_primary')
|
||||||
|
|
||||||
# Validate interface
|
# Validate interface
|
||||||
if device and interface_name:
|
if interface_name and device:
|
||||||
try:
|
try:
|
||||||
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
|
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
|
||||||
except Interface.DoesNotExist:
|
except Interface.DoesNotExist:
|
||||||
raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device))
|
raise forms.ValidationError("Invalid interface {} for device {}".format(
|
||||||
elif device and not interface_name:
|
interface_name, device
|
||||||
raise forms.ValidationError("Device set ({}) but interface missing".format(device))
|
))
|
||||||
elif interface_name and not device:
|
elif interface_name and virtual_machine:
|
||||||
raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name))
|
try:
|
||||||
|
self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name)
|
||||||
|
except Interface.DoesNotExist:
|
||||||
|
raise forms.ValidationError("Invalid interface {} for virtual machine {}".format(
|
||||||
|
interface_name, virtual_machine
|
||||||
|
))
|
||||||
|
elif interface_name:
|
||||||
|
raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format(
|
||||||
|
interface_name
|
||||||
|
))
|
||||||
|
elif device:
|
||||||
|
raise forms.ValidationError("Device specified ({}) but interface missing".format(device))
|
||||||
|
elif virtual_machine:
|
||||||
|
raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine))
|
||||||
|
|
||||||
# Validate is_primary
|
# Validate is_primary
|
||||||
if is_primary and not device:
|
if is_primary and not device and not virtual_machine:
|
||||||
raise forms.ValidationError("No device specified; cannot set as primary IP")
|
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
@@ -650,17 +647,22 @@ class IPAddressCSVForm(forms.ModelForm):
|
|||||||
device=self.cleaned_data['device'],
|
device=self.cleaned_data['device'],
|
||||||
name=self.cleaned_data['interface_name']
|
name=self.cleaned_data['interface_name']
|
||||||
)
|
)
|
||||||
|
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
|
||||||
|
self.instance.interface = Interface.objects.get(
|
||||||
|
virtual_machine=self.cleaned_data['virtual_machine'],
|
||||||
|
name=self.cleaned_data['interface_name']
|
||||||
|
)
|
||||||
|
|
||||||
ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs)
|
ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs)
|
||||||
|
|
||||||
# Set as primary for device
|
# Set as primary for device/VM
|
||||||
if self.cleaned_data['is_primary']:
|
if self.cleaned_data['is_primary']:
|
||||||
device = self.cleaned_data['device']
|
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
|
||||||
if self.instance.address.version == 4:
|
if self.instance.address.version == 4:
|
||||||
device.primary_ip4 = ipaddress
|
parent.primary_ip4 = ipaddress
|
||||||
elif self.instance.address.version == 6:
|
elif self.instance.address.version == 6:
|
||||||
device.primary_ip6 = ipaddress
|
parent.primary_ip6 = ipaddress
|
||||||
device.save()
|
parent.save()
|
||||||
|
|
||||||
return ipaddress
|
return ipaddress
|
||||||
|
|
||||||
@@ -677,18 +679,9 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['vrf', 'role', 'tenant', 'description']
|
nullable_fields = ['vrf', 'role', 'tenant', 'description']
|
||||||
|
|
||||||
|
|
||||||
def ipaddress_status_choices():
|
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||||
status_counts = {}
|
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
|
||||||
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
address = forms.CharField(label='IP Address')
|
||||||
status_counts[status['status']] = status['count']
|
|
||||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
|
|
||||||
|
|
||||||
|
|
||||||
def ipaddress_role_choices():
|
|
||||||
role_counts = {}
|
|
||||||
for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
|
|
||||||
role_counts[role['role']] = role['count']
|
|
||||||
return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
|
|
||||||
|
|
||||||
|
|
||||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
@@ -703,15 +696,25 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
|
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
|
||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label='VRF',
|
label='VRF',
|
||||||
null_option=(0, 'Global')
|
null_label='-- Global --'
|
||||||
)
|
)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
|
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
|
)
|
||||||
|
status = AnnotatedMultipleChoiceField(
|
||||||
|
choices=IPADDRESS_STATUS_CHOICES,
|
||||||
|
annotate=IPAddress.objects.all(),
|
||||||
|
annotate_field='status',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
role = AnnotatedMultipleChoiceField(
|
||||||
|
choices=IPADDRESS_ROLE_CHOICES,
|
||||||
|
annotate=IPAddress.objects.all(),
|
||||||
|
annotate_field='role',
|
||||||
|
required=False
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
|
||||||
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -726,11 +729,31 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = ['site', 'name', 'slug']
|
fields = ['site', 'name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class VLANGroupCSVForm(forms.ModelForm):
|
||||||
|
site = forms.ModelChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Name of parent site',
|
||||||
|
error_messages={
|
||||||
|
'invalid_choice': 'Site not found.',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
slug = SlugField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VLANGroup
|
||||||
|
fields = VLANGroup.csv_headers
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Name of VLAN group',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||||
site = FilterChoiceField(
|
site = FilterChoiceField(
|
||||||
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
|
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'Global')
|
null_label='-- Global --'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -810,7 +833,7 @@ class VLANCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLAN
|
model = VLAN
|
||||||
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
fields = VLAN.csv_headers
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'vid': 'Numeric VLAN ID (1-4095)',
|
'vid': 'Numeric VLAN ID (1-4095)',
|
||||||
'name': 'VLAN name',
|
'name': 'VLAN name',
|
||||||
@@ -847,36 +870,34 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
||||||
|
|
||||||
|
|
||||||
def vlan_status_choices():
|
|
||||||
status_counts = {}
|
|
||||||
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
|
||||||
status_counts[status['status']] = status['count']
|
|
||||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
|
||||||
|
|
||||||
|
|
||||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = VLAN
|
model = VLAN
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
site = FilterChoiceField(
|
site = FilterChoiceField(
|
||||||
queryset=Site.objects.annotate(filter_count=Count('vlans')),
|
queryset=Site.objects.annotate(filter_count=Count('vlans')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'Global')
|
null_label='-- Global --'
|
||||||
)
|
)
|
||||||
group_id = FilterChoiceField(
|
group_id = FilterChoiceField(
|
||||||
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
|
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
|
||||||
label='VLAN group',
|
label='VLAN group',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
|
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
|
)
|
||||||
|
status = AnnotatedMultipleChoiceField(
|
||||||
|
choices=VLAN_STATUS_CHOICES,
|
||||||
|
annotate=VLAN.objects.all(),
|
||||||
|
annotate_field='status',
|
||||||
|
required=False
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
|
|
||||||
role = FilterChoiceField(
|
role = FilterChoiceField(
|
||||||
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_option=(0, 'None')
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -898,5 +919,15 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
super(ServiceForm, self).__init__(*args, **kwargs)
|
super(ServiceForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit IP address choices to those assigned to interfaces of the parent device
|
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)
|
if self.instance.device:
|
||||||
|
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
|
||||||
|
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||||
|
interface_id__in=vc_interface_ids
|
||||||
|
)
|
||||||
|
elif self.instance.virtual_machine:
|
||||||
|
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||||
|
interface__virtual_machine=self.instance.virtual_machine
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.fields['ipaddresses'].choices = []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.db.models import Lookup, Transform, IntegerField
|
from django.db.models import Lookup, Transform, IntegerField
|
||||||
from django.db.models.lookups import BuiltinLookup
|
from django.db.models import lookups
|
||||||
|
|
||||||
|
|
||||||
class NetFieldDecoratorMixin(object):
|
class NetFieldDecoratorMixin(object):
|
||||||
@@ -13,28 +13,40 @@ class NetFieldDecoratorMixin(object):
|
|||||||
return lhs_string, lhs_params
|
return lhs_string, lhs_params
|
||||||
|
|
||||||
|
|
||||||
class EndsWith(NetFieldDecoratorMixin, BuiltinLookup):
|
class IExact(NetFieldDecoratorMixin, lookups.IExact):
|
||||||
lookup_name = 'endswith'
|
|
||||||
|
def get_rhs_op(self, connection, rhs):
|
||||||
|
return '= LOWER(%s)' % rhs
|
||||||
|
|
||||||
|
|
||||||
class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup):
|
class EndsWith(NetFieldDecoratorMixin, lookups.EndsWith):
|
||||||
lookup_name = 'iendswith'
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StartsWith(NetFieldDecoratorMixin, BuiltinLookup):
|
class IEndsWith(NetFieldDecoratorMixin, lookups.IEndsWith):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_rhs_op(self, connection, rhs):
|
||||||
|
return 'LIKE LOWER(%s)' % rhs
|
||||||
|
|
||||||
|
|
||||||
|
class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith):
|
||||||
lookup_name = 'startswith'
|
lookup_name = 'startswith'
|
||||||
|
|
||||||
|
|
||||||
class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup):
|
class IStartsWith(NetFieldDecoratorMixin, lookups.IStartsWith):
|
||||||
lookup_name = 'istartswith'
|
pass
|
||||||
|
|
||||||
|
def get_rhs_op(self, connection, rhs):
|
||||||
|
return 'LIKE LOWER(%s)' % rhs
|
||||||
|
|
||||||
|
|
||||||
class Regex(NetFieldDecoratorMixin, BuiltinLookup):
|
class Regex(NetFieldDecoratorMixin, lookups.Regex):
|
||||||
lookup_name = 'regex'
|
pass
|
||||||
|
|
||||||
|
|
||||||
class IRegex(NetFieldDecoratorMixin, BuiltinLookup):
|
class IRegex(NetFieldDecoratorMixin, lookups.IRegex):
|
||||||
lookup_name = 'iregex'
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NetContainsOrEquals(Lookup):
|
class NetContainsOrEquals(Lookup):
|
||||||
|
|||||||
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
31
netbox/ipam/migrations/0019_virtualization.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-08-31 15:44
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('virtualization', '0001_virtualization'),
|
||||||
|
('ipam', '0018_remove_service_uniqueness_constraint'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='service',
|
||||||
|
options={'ordering': ['protocol', 'port']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='virtual_machine',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='service',
|
||||||
|
name='device',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
netbox/ipam/migrations/0020_ipaddress_add_role_carp.py
Normal file
20
netbox/ipam/migrations/0020_ipaddress_add_role_carp.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.4 on 2017-10-09 20:02
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ipam', '0019_virtualization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ipaddress',
|
||||||
|
name='role',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
netbox/ipam/migrations/0021_vrf_ordering.py
Normal file
19
netbox/ipam/migrations/0021_vrf_ordering.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-07 18:37
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ipam', '0020_ipaddress_add_role_carp'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='vrf',
|
||||||
|
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
import netaddr
|
|
||||||
|
|
||||||
|
import netaddr
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
@@ -14,10 +15,9 @@ from dcim.models import Interface
|
|||||||
from extras.models import CustomFieldModel, CustomFieldValue
|
from extras.models import CustomFieldModel, CustomFieldValue
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.models import CreatedUpdatedModel
|
from utilities.models import CreatedUpdatedModel
|
||||||
from utilities.sql import NullsFirstQuerySet
|
|
||||||
from utilities.utils import csv_format
|
|
||||||
from .constants import *
|
from .constants import *
|
||||||
from .fields import IPNetworkField, IPAddressField
|
from .fields import IPNetworkField, IPAddressField
|
||||||
|
from .querysets import PrefixQuerySet
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@@ -38,7 +38,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name', 'rd']
|
||||||
verbose_name = 'VRF'
|
verbose_name = 'VRF'
|
||||||
verbose_name_plural = 'VRFs'
|
verbose_name_plural = 'VRFs'
|
||||||
|
|
||||||
@@ -49,13 +49,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return reverse('ipam:vrf', args=[self.pk])
|
return reverse('ipam:vrf', args=[self.pk])
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.name,
|
self.name,
|
||||||
self.rd,
|
self.rd,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.enforce_unique,
|
self.enforce_unique,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
@@ -75,6 +75,8 @@ class RIR(models.Model):
|
|||||||
is_private = models.BooleanField(default=False, verbose_name='Private',
|
is_private = models.BooleanField(default=False, verbose_name='Private',
|
||||||
help_text='IP space managed by this RIR is considered private')
|
help_text='IP space managed by this RIR is considered private')
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'is_private']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
verbose_name = 'RIR'
|
verbose_name = 'RIR'
|
||||||
@@ -86,6 +88,13 @@ class RIR(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
|
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.is_private,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
||||||
@@ -147,12 +156,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
super(Aggregate, self).save(*args, **kwargs)
|
super(Aggregate, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.prefix,
|
self.prefix,
|
||||||
self.rir.name,
|
self.rir.name,
|
||||||
self.date_added.isoformat() if self.date_added else None,
|
self.date_added,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
)
|
||||||
|
|
||||||
def get_utilization(self):
|
def get_utilization(self):
|
||||||
"""
|
"""
|
||||||
@@ -173,54 +182,20 @@ class Role(models.Model):
|
|||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
weight = models.PositiveSmallIntegerField(default=1000)
|
weight = models.PositiveSmallIntegerField(default=1000)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'weight']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['weight', 'name']
|
ordering = ['weight', 'name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@property
|
def to_csv(self):
|
||||||
def count_prefixes(self):
|
return (
|
||||||
return self.prefixes.count()
|
self.name,
|
||||||
|
self.slug,
|
||||||
@property
|
self.weight,
|
||||||
def count_vlans(self):
|
)
|
||||||
return self.vlans.count()
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixQuerySet(NullsFirstQuerySet):
|
|
||||||
|
|
||||||
def annotate_depth(self, limit=None):
|
|
||||||
"""
|
|
||||||
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
|
|
||||||
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
|
|
||||||
performance issues at scale.
|
|
||||||
|
|
||||||
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
|
|
||||||
modifications.
|
|
||||||
"""
|
|
||||||
queryset = self
|
|
||||||
stack = []
|
|
||||||
for p in queryset:
|
|
||||||
try:
|
|
||||||
prev_p = stack[-1]
|
|
||||||
except IndexError:
|
|
||||||
prev_p = None
|
|
||||||
if prev_p is not None:
|
|
||||||
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
|
|
||||||
stack.pop()
|
|
||||||
try:
|
|
||||||
prev_p = stack[-1]
|
|
||||||
except IndexError:
|
|
||||||
prev_p = None
|
|
||||||
break
|
|
||||||
if prev_p is not None:
|
|
||||||
prev_p.has_children = True
|
|
||||||
stack.append(p)
|
|
||||||
p.depth = len(stack) - 1
|
|
||||||
if limit is None:
|
|
||||||
return queryset
|
|
||||||
return list(filter(lambda p: p.depth <= limit, queryset))
|
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@@ -297,7 +272,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
super(Prefix, self).save(*args, **kwargs)
|
super(Prefix, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.prefix,
|
self.prefix,
|
||||||
self.vrf.rd if self.vrf else None,
|
self.vrf.rd if self.vrf else None,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
@@ -308,7 +283,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.role.name if self.role else None,
|
self.role.name if self.role else None,
|
||||||
self.is_pool,
|
self.is_pool,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
)
|
||||||
|
|
||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return STATUS_CHOICE_CLASSES[self.status]
|
return STATUS_CHOICE_CLASSES[self.status]
|
||||||
@@ -316,11 +291,35 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
def get_duplicates(self):
|
def get_duplicates(self):
|
||||||
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
|
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
|
||||||
|
|
||||||
|
def get_child_prefixes(self):
|
||||||
|
"""
|
||||||
|
Return all Prefixes within this Prefix and VRF. If this Prefix is a container in the global table, return child
|
||||||
|
Prefixes belonging to any VRF.
|
||||||
|
"""
|
||||||
|
if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
|
||||||
|
return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
|
||||||
|
else:
|
||||||
|
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
|
||||||
|
|
||||||
def get_child_ips(self):
|
def get_child_ips(self):
|
||||||
"""
|
"""
|
||||||
Return all IPAddresses within this Prefix.
|
Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
|
||||||
|
child IPAddresses belonging to any VRF.
|
||||||
"""
|
"""
|
||||||
return IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf)
|
if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
|
||||||
|
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
|
||||||
|
else:
|
||||||
|
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
|
||||||
|
|
||||||
|
def get_available_prefixes(self):
|
||||||
|
"""
|
||||||
|
Return all available Prefixes within this prefix as an IPSet.
|
||||||
|
"""
|
||||||
|
prefix = netaddr.IPSet(self.prefix)
|
||||||
|
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||||
|
available_prefixes = prefix - child_prefixes
|
||||||
|
|
||||||
|
return available_prefixes
|
||||||
|
|
||||||
def get_available_ips(self):
|
def get_available_ips(self):
|
||||||
"""
|
"""
|
||||||
@@ -339,6 +338,24 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
|
|
||||||
return available_ips
|
return available_ips
|
||||||
|
|
||||||
|
def get_first_available_prefix(self):
|
||||||
|
"""
|
||||||
|
Return the first available child prefix within the prefix (or None).
|
||||||
|
"""
|
||||||
|
available_prefixes = self.get_available_prefixes()
|
||||||
|
if not available_prefixes:
|
||||||
|
return None
|
||||||
|
return available_prefixes.iter_cidrs()[0]
|
||||||
|
|
||||||
|
def get_first_available_ip(self):
|
||||||
|
"""
|
||||||
|
Return the first available IP within the prefix (or None).
|
||||||
|
"""
|
||||||
|
available_ips = self.get_available_ips()
|
||||||
|
if not available_ips:
|
||||||
|
return None
|
||||||
|
return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
|
||||||
|
|
||||||
def get_utilization(self):
|
def get_utilization(self):
|
||||||
"""
|
"""
|
||||||
Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
|
Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
|
||||||
@@ -349,25 +366,13 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||||
else:
|
else:
|
||||||
child_count = IPAddress.objects.filter(
|
# Compile an IPSet to avoid counting duplicate IPs
|
||||||
address__net_contained_or_equal=str(self.prefix), vrf=self.vrf
|
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||||
).count()
|
|
||||||
prefix_size = self.prefix.size
|
prefix_size = self.prefix.size
|
||||||
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||||
prefix_size -= 2
|
prefix_size -= 2
|
||||||
return int(float(child_count) / prefix_size * 100)
|
return int(float(child_count) / prefix_size * 100)
|
||||||
|
|
||||||
@property
|
|
||||||
def new_subnet(self):
|
|
||||||
if self.family == 4:
|
|
||||||
if self.prefix.prefixlen <= 30:
|
|
||||||
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
|
|
||||||
return None
|
|
||||||
if self.family == 6:
|
|
||||||
if self.prefix.prefixlen <= 126:
|
|
||||||
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class IPAddressManager(models.Manager):
|
class IPAddressManager(models.Manager):
|
||||||
|
|
||||||
@@ -418,7 +423,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
objects = IPAddressManager()
|
objects = IPAddressManager()
|
||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description',
|
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
|
||||||
|
'description',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -466,17 +472,18 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
else:
|
else:
|
||||||
is_primary = False
|
is_primary = False
|
||||||
|
|
||||||
return csv_format([
|
return (
|
||||||
self.address,
|
self.address,
|
||||||
self.vrf.rd if self.vrf else None,
|
self.vrf.rd if self.vrf else None,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.get_status_display(),
|
self.get_status_display(),
|
||||||
self.get_role_display(),
|
self.get_role_display(),
|
||||||
self.device.identifier if self.device else None,
|
self.device.identifier if self.device else None,
|
||||||
|
self.virtual_machine.name if self.virtual_machine else None,
|
||||||
self.interface.name if self.interface else None,
|
self.interface.name if self.interface else None,
|
||||||
is_primary,
|
is_primary,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device(self):
|
def device(self):
|
||||||
@@ -484,9 +491,18 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return self.interface.device
|
return self.interface.device
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virtual_machine(self):
|
||||||
|
if self.interface:
|
||||||
|
return self.interface.virtual_machine
|
||||||
|
return None
|
||||||
|
|
||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return STATUS_CHOICE_CLASSES[self.status]
|
return STATUS_CHOICE_CLASSES[self.status]
|
||||||
|
|
||||||
|
def get_role_class(self):
|
||||||
|
return ROLE_CHOICE_CLASSES[self.role]
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class VLANGroup(models.Model):
|
class VLANGroup(models.Model):
|
||||||
@@ -497,6 +513,8 @@ class VLANGroup(models.Model):
|
|||||||
slug = models.SlugField()
|
slug = models.SlugField()
|
||||||
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
|
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
|
||||||
|
|
||||||
|
csv_headers = ['name', 'slug', 'site']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['site', 'name']
|
ordering = ['site', 'name']
|
||||||
unique_together = [
|
unique_together = [
|
||||||
@@ -512,6 +530,23 @@ class VLANGroup(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
|
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
|
||||||
|
|
||||||
|
def to_csv(self):
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
self.site.name if self.site else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_next_available_vid(self):
|
||||||
|
"""
|
||||||
|
Return the first available VLAN ID (1-4094) in the group.
|
||||||
|
"""
|
||||||
|
vids = [vlan['vid'] for vlan in self.vlans.order_by('vid').values('vid')]
|
||||||
|
for i in range(1, 4095):
|
||||||
|
if i not in vids:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||||
@@ -562,7 +597,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return csv_format([
|
return (
|
||||||
self.site.name if self.site else None,
|
self.site.name if self.site else None,
|
||||||
self.group.name if self.group else None,
|
self.group.name if self.group else None,
|
||||||
self.vid,
|
self.vid,
|
||||||
@@ -571,7 +606,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.get_status_display(),
|
self.get_status_display(),
|
||||||
self.role.name if self.role else None,
|
self.role.name if self.role else None,
|
||||||
self.description,
|
self.description,
|
||||||
])
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
@@ -582,24 +617,70 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return STATUS_CHOICE_CLASSES[self.status]
|
return STATUS_CHOICE_CLASSES[self.status]
|
||||||
|
|
||||||
|
def get_members(self):
|
||||||
|
# Return all interfaces assigned to this VLAN
|
||||||
|
return Interface.objects.filter(
|
||||||
|
Q(untagged_vlan_id=self.pk) |
|
||||||
|
Q(tagged_vlans=self.pk)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Service(CreatedUpdatedModel):
|
class Service(CreatedUpdatedModel):
|
||||||
"""
|
"""
|
||||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
|
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
||||||
to one or more specific IPAddresses belonging to the Device.
|
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
||||||
"""
|
"""
|
||||||
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
|
device = models.ForeignKey(
|
||||||
name = models.CharField(max_length=30)
|
to='dcim.Device',
|
||||||
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
|
on_delete=models.CASCADE,
|
||||||
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
related_name='services',
|
||||||
verbose_name='Port number')
|
verbose_name='device',
|
||||||
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
|
null=True,
|
||||||
verbose_name='IP addresses')
|
blank=True
|
||||||
description = models.CharField(max_length=100, blank=True)
|
)
|
||||||
|
virtual_machine = models.ForeignKey(
|
||||||
|
to='virtualization.VirtualMachine',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='services',
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=30
|
||||||
|
)
|
||||||
|
protocol = models.PositiveSmallIntegerField(
|
||||||
|
choices=IP_PROTOCOL_CHOICES
|
||||||
|
)
|
||||||
|
port = models.PositiveIntegerField(
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||||
|
verbose_name='Port number'
|
||||||
|
)
|
||||||
|
ipaddresses = models.ManyToManyField(
|
||||||
|
to='ipam.IPAddress',
|
||||||
|
related_name='services',
|
||||||
|
blank=True,
|
||||||
|
verbose_name='IP addresses'
|
||||||
|
)
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['device', 'protocol', 'port']
|
ordering = ['protocol', 'port']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent(self):
|
||||||
|
return self.device or self.virtual_machine
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# A Service must belong to a Device *or* to a VirtualMachine
|
||||||
|
if self.device and self.virtual_machine:
|
||||||
|
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
|
||||||
|
if not self.device and not self.virtual_machine:
|
||||||
|
raise ValidationError("A service must be associated with either a device or a virtual machine.")
|
||||||
|
|||||||
38
netbox/ipam/querysets.py
Normal file
38
netbox/ipam/querysets.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from utilities.sql import NullsFirstQuerySet
|
||||||
|
|
||||||
|
|
||||||
|
class PrefixQuerySet(NullsFirstQuerySet):
|
||||||
|
|
||||||
|
def annotate_depth(self, limit=None):
|
||||||
|
"""
|
||||||
|
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
|
||||||
|
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
|
||||||
|
performance issues at scale.
|
||||||
|
|
||||||
|
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
|
||||||
|
modifications.
|
||||||
|
"""
|
||||||
|
queryset = self
|
||||||
|
stack = []
|
||||||
|
for p in queryset:
|
||||||
|
try:
|
||||||
|
prev_p = stack[-1]
|
||||||
|
except IndexError:
|
||||||
|
prev_p = None
|
||||||
|
if prev_p is not None:
|
||||||
|
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
|
||||||
|
stack.pop()
|
||||||
|
try:
|
||||||
|
prev_p = stack[-1]
|
||||||
|
except IndexError:
|
||||||
|
prev_p = None
|
||||||
|
break
|
||||||
|
if prev_p is not None:
|
||||||
|
prev_p.has_children = True
|
||||||
|
stack.append(p)
|
||||||
|
p.depth = len(stack) - 1
|
||||||
|
if limit is None:
|
||||||
|
return queryset
|
||||||
|
return list(filter(lambda p: p.depth <= limit, queryset))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user