From 2979a64ce3c0983f2aee7c7a49141bb5c092e1b1 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:11:02 -0700 Subject: [PATCH 01/58] add file, skeleton from "select all" --- netbox/project-static/src/buttons/selectMultiple.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 netbox/project-static/src/buttons/selectMultiple.ts diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts new file mode 100644 index 000000000..465edc2f3 --- /dev/null +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -0,0 +1,5 @@ +import { getElement, getElements, findFirstAdjacent } from '../util'; + +export function initSelectMultiple(): void { +} + From 2e38e621017e8d6de0057c8c767b60aee9423063 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:13:02 -0700 Subject: [PATCH 02/58] create store to store previously checked element --- netbox/project-static/src/stores/previousPkCheck.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 netbox/project-static/src/stores/previousPkCheck.ts diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts new file mode 100644 index 000000000..7fba2faba --- /dev/null +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -0,0 +1,7 @@ +import { createState } from '../state'; + +export const previousPKCheckState = createState<{ hidden: boolean }>( + { hidden: false }, + { persist: false }, +); + From ae7ddecaa65f7ccff69352fb9d75bf775b6c0b6c Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:14:15 -0700 Subject: [PATCH 03/58] now exports previousPkCheck.ts --- netbox/project-static/src/stores/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 42d4aa0b5..5e53410ad 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; +export * from './previousPkCheck'; \ No newline at end of file From c536944a101d87bd38f1c33d1c14a0788e453251 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:36:17 -0700 Subject: [PATCH 04/58] now exports multiselect function --- netbox/project-static/src/buttons/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6a9001cd1..e677ff599 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initSelectMultiple } from './selectMultiple'; export function initButtons(): void { for (const func of [ @@ -10,6 +11,7 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, + initSelectMultiple, initMoveButtons, ]) { func(); From db142061ffc77cd16bcc89df199f070697873694 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:37:28 -0700 Subject: [PATCH 05/58] clicking a PkCheckbox updates state --- netbox/project-static/dist/netbox.js | Bin 375393 -> 375642 bytes netbox/project-static/dist/netbox.js.map | Bin 344719 -> 345022 bytes .../src/buttons/selectMultiple.ts | 20 ++++++++++++++++-- .../src/stores/previousPkCheck.ts | 6 +++--- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index acd1abbf28c867420280fb8364a550d7f54c9ec4..3d6bb9d1a610d4977fd9c887d49450d097f78f7d 100644 GIT binary patch delta 27130 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 }; + +function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { + console.log(state) + state.set('element', eventTargetElement); } + +export function initSelectMultiple(): void { + const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); + for (const element of checkboxElements) { + element.addEventListener('click', (event) => { + event.stopPropagation(); + updatePreviousPkCheckState(event.target as HTMLInputElement, previousPkCheckState); + }); + } +} diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts index 7fba2faba..a5d06ceee 100644 --- a/netbox/project-static/src/stores/previousPkCheck.ts +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -1,7 +1,7 @@ import { createState } from '../state'; -export const previousPKCheckState = createState<{ hidden: boolean }>( - { hidden: false }, - { persist: false }, +export const previousPkCheckState = createState<{ element: Nullable }>( + { element: null}, + { persist: false } ); From ea9258d36cabaf5de25c45f65fd92bfc5959bbe0 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:23:43 -0700 Subject: [PATCH 06/58] added main multi-select function --- .../src/buttons/selectMultiple.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 08b5165e2..68cd57032 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -9,6 +9,43 @@ function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: state.set('element', eventTargetElement); } +function handlePkCheck(event: _MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if(event.shiftKey === false){ + return + } + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + let changePkCheckboxState = false; + for(const element of checkboxList){ + //The previously clicked checkbox was above the shift clicked checkbox + if(element === previousStateElement){ + if(changePkCheckboxState === true){ + changePkCheckboxState = false; + return + } + changePkCheckboxState = true; + } + //Change loop's current checkbox state to eventTargetElement checkbox state + if(changePkCheckboxState === true){ + element.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was below the shift clicked checkbox + if(element === eventTargetElement){ + if(changePkCheckboxState === true){ + changePkCheckboxState = false + return + } + changePkCheckboxState = true; + } + } +} export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); From 1493c920fd1a49f8aac53584abcb61c24a49078f Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:24:12 -0700 Subject: [PATCH 07/58] silly text highlight workaround... --- netbox/project-static/src/buttons/selectMultiple.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 68cd57032..62e66ed0a 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,6 +4,10 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; +function preventTextHighlight(): void { + return +} + function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { console.log(state) state.set('element', eventTargetElement); From 3effa37fa77a5123c65dd85e6f87599b99b3b133 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:24:50 -0700 Subject: [PATCH 08/58] click event calls multiselect function --- netbox/project-static/dist/netbox.js | Bin 375642 -> 376041 bytes netbox/project-static/dist/netbox.js.map | Bin 345022 -> 345446 bytes .../src/buttons/selectMultiple.ts | 9 +++++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 3d6bb9d1a610d4977fd9c887d49450d097f78f7d..b7095fa78873efc9370ca09754b6e9cf3b8641cb 100644 GIT binary patch delta 22477 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 O55LBxFYi`+XZ=4EDc3Im delta 22089 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 delta 104 zcmaFXCAzO&w4sHug{g(Pg=GtCs>1X_1y%{AKu5r#5S9LpjwVhzzRqE$F5CAju`X8v05p^xJ^%m! diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 62e66ed0a..dc33e4fc5 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -9,7 +9,6 @@ function preventTextHighlight(): void { } function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { - console.log(state) state.set('element', eventTargetElement); } @@ -55,8 +54,14 @@ export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { element.addEventListener('click', (event) => { + //Prevents shift+click from selecting table text + document.addEventListener('selectstart', preventTextHighlight) + //Stop propogation to avoid event firing multiple times event.stopPropagation(); - updatePreviousPkCheckState(event.target as HTMLInputElement, previousPkCheckState); + //Main logic for multi select + handlePkCheck(event, previousPkCheckState); + //Re-enables user's ability to select table text + document.removeEventListener('selectstart', preventTextHighlight) }); } } From ef29bffb723c86b51f1ae00ae65b59786ae5298c Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:27:09 -0700 Subject: [PATCH 09/58] is this supposed to be ignored? --- netbox/project-static/dist/netbox.js.map | Bin 345446 -> 345447 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 8538f4c2a701fe35487ac56132d7e08be5a42f1f..61469e070456ceeedee6d1878b86d8a63390bc29 100644 GIT binary patch delta 92 zcmaFXCHlNew4sHug{g(Pg=Gutas}ReCmnZ3$4nO;XGdql?VA-?_cJoOPA^tsRZ@5L r14$)=2!9< Date: Thu, 5 May 2022 15:01:40 -0700 Subject: [PATCH 10/58] fixed text deselection and refactor --- netbox/project-static/dist/netbox.js | Bin 376041 -> 376078 bytes netbox/project-static/dist/netbox.js.map | Bin 345447 -> 345520 bytes .../src/buttons/selectMultiple.ts | 52 +++++++++--------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index b7095fa78873efc9370ca09754b6e9cf3b8641cb..ce2e0efd2ede7b7f41e1b1837cc1373283e0cee2 100644 GIT binary patch delta 1655 zcmaizdr(wW9LMLoyGLn!G?gw+BC*oQu5&YSv>By!1Vuy^L|9*#@p6}iyL&IY?5>ZM z)D%GxMGwnCAS^SbNRh63H7B3VsF{{&Qa&J`X}-&5vYDFBBG&jGg;(D}fF9oUo(4|O&LP)Kh!t%0n}+xR?CqLja% z1?18zANb&;GdEG>D|a>p0lV_<<_5q)BeyUV)7@KI;G%h3y(pmVTV?oZQgbx&6tP*< zVu8}$GDm|%I%>NQ*|cSQF*0dHYZ>M#n_3fr`Sj+FN3bfF+LD2I8l!B1g`QHrzyxLW z&KMw7*}1C|y^>>vVpnMU#V)>mJ>3!1(LL(h5S8#fZXlhm+?&Ix{<4>Zs6P074yT&7 z?^6wN#|l;#TNjD`wBP<^WKyg!r8{if&oEI*KA_id#5so+Uu?TcQT>q< zoaU7ysYp_Wf0?J{IO)-Sm`tY}i{>;-jx`}mdGWXj#O4;Hg02?Iw6Ds*xn4NYN+h=+ zKlw{Hf8EIeFFzSXq4M&nsUWgj$m^~s`=&}qO58$bcPyN}2a@X+tlja*a}65g({F!p z!l#&jT*~Rw1LqfV_+u_m4*%4JMz|F5;s9{^%P(E!+#MYOaNyTEWn?SXpWXl`UU}Ki ziEFNWq~*k$uh+p!<8Rbp3O#q@7{=3$KmUe!h2ET~<@C?rUd!nh-C4rv)7y6sBU#yg zFO%p*w-8U;?&+0*k5&*}x?2#tqJ57?>yV-R{+BllMQTwG)b)g0)%L~OzDQDO1U>;~ zvayj!LzY?#9+WSc+^${&JdKS%VKa*gIty~6n;3>_Mxx=KGiH>b|2l0 z1RLz{B|1)}v6(sRhwNR_*oZ$1g{DpaJ~R zsDTD;`_!<)h2{*8F~h;+d)of0d{=uIaH%8iYc2fjtBW6MGlqm&6G9rlJ*3f>-w)TR z_rpn+>p6d}C(0|u8NKY`IfM@~naz^V>0GqPD-HA5T(aL5?{sG5N(HtOqlqP#kui$} zNiqeTHgA+iG8g?1xPnazgbLxvb-X;{9)mM_}onz?27r%8}Q z0^2iUQ{aiA$L95Uq|#6@oa>&}JS~soR;9c}mxK3z9ZE|}?LH~rTgcZ}z!sH}HzN#u zAtjDNyEnzQ!1SbB95+l%G@1l)_iF1iYNfJB~?{8w;R*+&tS61SMUb#5kBNyiu zTGO>b;KsIZt zAghqaOe@JC_|>G9WHEXtibA$H$7GX?UWtj7EC&HL-UwDU)^3sqBXFx@79tucD9-fV&-bXFrFEz zNd$XX%{}AQ@7IxKTDBob!kHW-L)oPuw{H!SVO-Y{B$K!#rj{&;fQ2=GMjpbV)+w$Qbl&CibSz%yNCs;YV2;Zr^i2ocWmzf delta 1606 zcmZXTdr(wW9LMLoyGNPPXfj=xKmu`)b#4xrHlxlu0`+a}k-<;1m zck2`F?Z?`V`SnPU7u3HB#M4#VlaVC1Z(ju1=>zJ*o7B>fiAZ|3p&v_Vo!f@#6pbkp z$_j*-Je_`_D<+SIw@#(yjgeSRk2M}d8WmYAQsrLe2dtz6O~(*Jn|5SiDgA3l4_3&X zJJW!-s8>rc5~A7+517@+ zmIvBat58bk9x8%?wjHt|gZj5uAX(nsZUENN;f_Zrk*{<{15tE_yblrdocsl1<@dT~ z0E^|W?jcNw3m0sKp5xmJ`SG>1!>yq`${Iw=K0S88Ot&1#4fjqm+b zMGC@<NY?fGKqH(M`l2E-V`hJC8C%%F%sV6-S(TycA`0@c1Cd+1;OsRH{93hSR)$ zA`UTf*q7OAj+373#R|IYR4}LMJk^RsdD3YeNP4&+<~|)Er+!rd&h^rnb|TpY>u8ql z{d$N4-h9>#hy2R9M38*DkUiE>@lCCUSnWdcSS)-$0^+m_DP!@Ri_I!n=(j&wkR$7V z+Q{kCzQHmMfBq%P;h($I0-J2U>BsZ0Fm))+YX14*$ARRzGB*m$^7T2ay5g0D#g{T z9!T_ZlIk(M zr9g`qXf*{|MV*T)EF$l5jPALN_lN0Rqxw;sa!(Le&ZT4JkPzN9D<{vGV>YhUi_f+? z;i;GSKd(ui;m?_sv(PGHV?)vK3v(JR#vI8hWjUm2g2A8@xT90D+7cXATUKtCly4P7 zbwZ&zR1^ghFEj4~O@R0FUF=8|NqH&Pte+N}pPMHI##z@IrwPR@pqeZ~Hp{3cVJKyF z)nqmd>_jzr7sV`o3z>>s#j=I0hi|D_Fql{CjAE!H>dH+;Vvc8SUVX z4VljRYRC<4xaU36s#<4e;T>cOo3&9hj;*OB;mBZhwIuB2WV7xWYstfU)jH)uEeTe! zh+3j$6YI!4q_8J-#Ghr?5ylL4WGd6vbLS}KNj=%5MhWY4b7g~@%w*+m;=_J$lh@c$ zPtLcIyv}v8jbwvABG}o_$U{UZeKOGkvFu_O>EcUGH+dV2*>E>0#9}4wFzFfhFCOe! AJpcdz diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 61469e070456ceeedee6d1878b86d8a63390bc29..9814304071db1bcd00884cef28580bc2cee1a997 100644 GIT binary patch delta 312 zcmX9(yH3ME5EPI50E!eKglr4CZ7jqi6`EZ;Ckr{oK`2U1Lxdof!b_&1a78}gK7p1Z zh?XCr<~O*#G^5q*?9A-nd+&46d+XM^^=_lvT$-EcU?wCOHbNDuoW?*!QwA>R5_n{~ zW!RQo6Pfhvn8^ZjQG5>Q8G$?$$`n+1S|xJC-VHUCz*yOdSCuF3D>kWnjm_zvyacP7 zYT!zb@7Ry_DVSRK8ynv4GfhH;)!O4U)3nwtBa`n65Y(kFDkGm)47||*bdKC@#iqY= spQ-aKB&~4HzKv}&7bj-4FNdOHsH)UQWvJt|LPe#dS;OByiTOzW0i(87L;wH) delta 239 zcmdncE&9Amw4sHug{g(Pg=GtCh5|>ivyQ8yXNmLlUIkV|;Zi3ZcSpy3Fq!G1Jy?_A)d|b zf^;}Lx@LeCxKCGAWOZcnH=O<#Xxa2$MOI6uGN }; -function preventTextHighlight(): void { - return +function removeTextSelection(): void{ + window.getSelection()?.removeAllRanges(); } function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { state.set('element', eventTargetElement); } -function handlePkCheck(event: _MouseEvent, state: StateManager): void { - const eventTargetElement = event.target as HTMLInputElement; - const previousStateElement = state.get('element'); - updatePreviousPkCheckState(eventTargetElement, state); - //Stop if user is not holding shift key - if(event.shiftKey === false){ - return - } - //If no previous state, store event target element as previous state and return - if (previousStateElement === null) { - return updatePreviousPkCheckState(eventTargetElement, state); - } - const checkboxList = getElements('input[type="checkbox"][name="pk"]'); - let changePkCheckboxState = false; - for(const element of checkboxList){ +function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ + let changePkCheckboxState = false + for(let element of elementList){ + //Change loop's current checkbox state to eventTargetElement checkbox state + if(changePkCheckboxState === true){ + element.checked = eventTargetElement.checked; + } //The previously clicked checkbox was above the shift clicked checkbox if(element === previousStateElement){ if(changePkCheckboxState === true){ @@ -34,9 +26,6 @@ function handlePkCheck(event: _MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if(!event.shiftKey){ + return + } + removeTextSelection(); + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList) +} + export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { element.addEventListener('click', (event) => { - //Prevents shift+click from selecting table text - document.addEventListener('selectstart', preventTextHighlight) + removeTextSelection() //Stop propogation to avoid event firing multiple times event.stopPropagation(); - //Main logic for multi select handlePkCheck(event, previousPkCheckState); - //Re-enables user's ability to select table text - document.removeEventListener('selectstart', preventTextHighlight) }); } } From 90d8395a2c9438b99be7ac0213e08f38cbc2225c Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 15:24:16 -0700 Subject: [PATCH 11/58] Fixed variable type issue...i think. --- netbox/project-static/src/buttons/selectMultiple.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 0ec19672c..8d75fb866 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -14,10 +14,11 @@ function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ let changePkCheckboxState = false - for(let element of elementList){ + for(const element of elementList){ + const typedElement = element as HTMLInputElement //Change loop's current checkbox state to eventTargetElement checkbox state if(changePkCheckboxState === true){ - element.checked = eventTargetElement.checked; + typedElement.checked = eventTargetElement.checked; } //The previously clicked checkbox was above the shift clicked checkbox if(element === previousStateElement){ @@ -26,7 +27,7 @@ function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousState return } changePkCheckboxState = true; - element.checked = eventTargetElement.checked; + typedElement.checked = eventTargetElement.checked; } //The previously clicked checkbox was below the shift clicked checkbox if(element === eventTargetElement){ From 491a4e7d787a75c0560136d597d491a3c2266bf0 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:33:00 -0700 Subject: [PATCH 12/58] various punctuation and spacing fixes --- .../src/buttons/selectMultiple.ts | 49 +++++++++++-------- netbox/project-static/src/stores/index.ts | 2 +- .../src/stores/previousPkCheck.ts | 5 +- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 8d75fb866..8a5d2aabb 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,36 +4,43 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; -function removeTextSelection(): void{ +function removeTextSelection(): void { window.getSelection()?.removeAllRanges(); } -function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { +function updatePreviousPkCheckState( + eventTargetElement: HTMLInputElement, + state: StateManager, +): void { state.set('element', eventTargetElement); } -function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ - let changePkCheckboxState = false - for(const element of elementList){ - const typedElement = element as HTMLInputElement +function toggleCheckboxRange( + eventTargetElement: HTMLInputElement, + previousStateElement: HTMLInputElement, + elementList: Generator, +): void { + let changePkCheckboxState = false; + for (const element of elementList) { + const typedElement = element as HTMLInputElement; //Change loop's current checkbox state to eventTargetElement checkbox state - if(changePkCheckboxState === true){ + if (changePkCheckboxState === true) { typedElement.checked = eventTargetElement.checked; } - //The previously clicked checkbox was above the shift clicked checkbox - if(element === previousStateElement){ - if(changePkCheckboxState === true){ + //The previously clicked checkbox was above the shift clicked checkbox + if (element === previousStateElement) { + if (changePkCheckboxState === true) { changePkCheckboxState = false; - return + return; } changePkCheckboxState = true; typedElement.checked = eventTargetElement.checked; } - //The previously clicked checkbox was below the shift clicked checkbox - if(element === eventTargetElement){ - if(changePkCheckboxState === true){ - changePkCheckboxState = false - return + //The previously clicked checkbox was below the shift clicked checkbox + if (element === eventTargetElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; } changePkCheckboxState = true; } @@ -45,8 +52,8 @@ function handlePkCheck(event: MouseEvent, state: StateManager('input[type="checkbox"][name="pk"]'); - toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList) + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList); } export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { - element.addEventListener('click', (event) => { - removeTextSelection() + element.addEventListener('click', event => { + removeTextSelection(); //Stop propogation to avoid event firing multiple times event.stopPropagation(); handlePkCheck(event, previousPkCheckState); diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 5e53410ad..d4644e619 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,3 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; -export * from './previousPkCheck'; \ No newline at end of file +export * from './previousPkCheck'; diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts index a5d06ceee..19b244ec7 100644 --- a/netbox/project-static/src/stores/previousPkCheck.ts +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -1,7 +1,6 @@ import { createState } from '../state'; export const previousPkCheckState = createState<{ element: Nullable }>( - { element: null}, - { persist: false } + { element: null }, + { persist: false }, ); - From 9c5355a300e4a2139d12f3ff9a60e5415c00e38f Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:43:18 -0700 Subject: [PATCH 13/58] added JSDoc comments --- .../src/buttons/selectMultiple.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 8a5d2aabb..d05c21716 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,10 +4,20 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; +/** + * If there is a text selection, removes it. + */ function removeTextSelection(): void { window.getSelection()?.removeAllRanges(); } +/** + * Sets the state object passed in to the eventTargetElement object passed in. + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ function updatePreviousPkCheckState( eventTargetElement: HTMLInputElement, state: StateManager, @@ -15,6 +25,14 @@ function updatePreviousPkCheckState( state.set('element', eventTargetElement); } +/** + * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle + * "checked" value to eventTargetElement.checked + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ function toggleCheckboxRange( eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, @@ -47,6 +65,14 @@ function toggleCheckboxRange( } } + +/** + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * event target element and the state element. + * + * @param event Mouse event. + * @param state PreviousPkCheckState object. + */ function handlePkCheck(event: MouseEvent, state: StateManager): void { const eventTargetElement = event.target as HTMLInputElement; const previousStateElement = state.get('element'); @@ -64,6 +90,9 @@ function handlePkCheck(event: MouseEvent, state: StateManager('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { From fbd933b56a82c774853e347627909fe6788f2848 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:44:34 -0700 Subject: [PATCH 14/58] prettier fixes --- .../project-static/src/buttons/selectMultiple.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index d05c21716..d8bad3105 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -13,9 +13,9 @@ function removeTextSelection(): void { /** * Sets the state object passed in to the eventTargetElement object passed in. - * + * * @param eventTargetElement HTML Input Element, retrieved from getting the target of the - * event passed in from handlePkCheck() + * event passed in from handlePkCheck() * @param state PreviousPkCheckState object. */ function updatePreviousPkCheckState( @@ -27,10 +27,10 @@ function updatePreviousPkCheckState( /** * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle - * "checked" value to eventTargetElement.checked - * + * "checked" value to eventTargetElement.checked + * * @param eventTargetElement HTML Input Element, retrieved from getting the target of the - * event passed in from handlePkCheck() + * event passed in from handlePkCheck() * @param state PreviousPkCheckState object. */ function toggleCheckboxRange( @@ -65,11 +65,10 @@ function toggleCheckboxRange( } } - /** - * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the * event target element and the state element. - * + * * @param event Mouse event. * @param state PreviousPkCheckState object. */ From 124e93f73726d99a0383768e797d3ad774a2e6ef Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 12:16:45 -0700 Subject: [PATCH 15/58] yarn bundle. --- netbox/project-static/dist/netbox.js | Bin 376078 -> 376088 bytes netbox/project-static/dist/netbox.js.map | Bin 345520 -> 345522 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ce2e0efd2ede7b7f41e1b1837cc1373283e0cee2..ce02d4bbb227926941d2adc3ae38e721d48ce21b 100644 GIT binary patch delta 57 zcmeDCB{t)iSVIeA3sVbo3(FSPhpiGhsU-@DdA9j^)|qJX&Oa&wzjs425M>=`O|;4vf44`Z?|Y;eP9Lv DuRjqe diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 9814304071db1bcd00884cef28580bc2cee1a997..e21571e0c84f1680154cb438612199fef77a0f4e 100644 GIT binary patch delta 377 zcmY+AJxc>Y5QfReBG_3(un}_>HdBa_BB0>AOLCsaCWhoV4ne%aLXD^qja;L}bpgR@ zOKUq3!GGXi@ozYDeuOm3?#%N(Gwkc6_BN?K}r=eNR zA^Vd=D$uDB$_jPNH#-t^FQ5=u*Ibnp_fqNsk)Y?A7xM4cj39|ruC<6R3(;S&aB_9% z!n*RdXW~8hjVW9xE(*nPTIJa@ils`3#C_J)!NR8e6Mon3!wlSprw&WjJ$Y)^J#Q&h T<-wWz^3-y<+5VVYRWIH*)~sdF delta 390 zcmY+Ay-EW?6opB2iZlU>6e37=ZEv9j6$^_q6Pc{bx~{T>MF`p%qp0`?+r(nKJiz!0 zb}E8TVe32i7T!BSLYm=n?)lC=%*UklHfcRqwW?k%REwYLv^~DCrJ*z6+K|D)jiD6j z9`%8gh8j4eW8gv4NW+$z3U9Q)ogsdQU4~5AlR2RXh$sj8JlRKY-qNy~nx8zv3M2QC z=n+Nkr>&ZUjFKsag15=GQ2Ul*U|n;V>Bx(WG9VZ5N^>pCW!X`PaO+bq{e^#y Date: Fri, 3 Jun 2022 13:03:58 +0200 Subject: [PATCH 16/58] Clear webhook queue on script failure --- netbox/extras/management/commands/runscript.py | 5 +++-- netbox/extras/scripts.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 12188619f..2296ce1ff 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices from extras.context_managers import change_logging from extras.models import JobResult from extras.scripts import get_script +from extras.signals import clear_webhooks from utilities.exceptions import AbortTransaction from utilities.utils import NetBoxFakeRequest @@ -49,7 +50,7 @@ class Command(BaseCommand): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -58,7 +59,7 @@ class Command(BaseCommand): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..e36cbb5a8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -17,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction @@ -458,7 +459,7 @@ def run_script(data, request, commit=True, *args, **kwargs): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -467,7 +468,7 @@ def run_script(data, request, commit=True, *args, **kwargs): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() From bb2d21abdd552e028392f685a0f0d68710d2961f Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sun, 5 Jun 2022 10:31:21 +0200 Subject: [PATCH 17/58] Make the Service and ServiceTemplate tables sortable by ports --- netbox/ipam/tables/services.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' From 9f4e565b8e1c25ab142305700bbc1d4254d727f2 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 6 Jun 2022 16:28:33 +0200 Subject: [PATCH 18/58] List services listening on all IPs in IPAddressView --- netbox/ipam/views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 84f6db6d5..a01f2d052 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -7,12 +7,12 @@ from django.urls import reverse from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) - services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) return { 'parent_prefixes_table': parent_prefixes_table, From 15080aad6662e63846e1fe9c0e9fc42b95ee4d58 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 08:51:53 -0400 Subject: [PATCH 19/58] Changelog for #9480, #9484 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ea5e580b8..baab085ce 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.5 (FUTURE) +### Bug Fixes + +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view + --- ## v3.2.4 (2022-05-31) From 1b8350fe48323c2e5961e1b2add65948b54094b6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 09:59:59 -0400 Subject: [PATCH 20/58] Add warning against bumping stale issues --- .github/workflows/stale.yml | 5 ++++- CONTRIBUTING.md | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7390ec1df..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01adf4c9..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance From 6ed2dbf17288a5e5939c7156189c34a730cb912c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 10:06:19 -0400 Subject: [PATCH 21/58] Fixes #9486: Fix redirect URL when adding device components from the module view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/module.html | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index baab085ce..3ffc33e71 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view --- diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@ From 8a4c808be5d99447e7bafbbdbf54001a188f881d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:00:14 -0400 Subject: [PATCH 22/58] Closes #8882: Support filtering IP addresses by multiple parent prefixes --- docs/release-notes/version-3.2.md | 4 ++++ netbox/ipam/filtersets.py | 16 +++++++++------- netbox/ipam/tests/test_filtersets.py | 6 ++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3ffc33e71..4534c35c1 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.5 (FUTURE) +### Enhancements + +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes + ### Bug Fixes * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a445022ca..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 198f9d62d..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask From 36c65b7b22566517b8d3abb7856f0b1d18402a3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:12:40 -0400 Subject: [PATCH 23/58] Closes #8893: Include count of IP ranges under tenant view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/tenancy/tenant.html | 4 ++++ netbox/tenancy/views.py | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 4534c35c1..3d1f86bec 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,7 @@ ### Enhancements * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view ### Bug Fixes diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

{{ stats.prefix_count }}

Prefixes

+
+

{{ stats.iprange_count }}

+

IP Ranges

+

{{ stats.ipaddress_count }}

IP addresses

diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 58ad98e8f..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), From c81c3d11eda8fc0659acfabab042bc6065b43051 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 10:20:44 -0400 Subject: [PATCH 24/58] Fixes #9495: Correct link to contacts in contact groups table column --- docs/release-notes/version-3.2.md | 1 + netbox/tenancy/tables/contacts.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3d1f86bec..339081902 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column --- diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( From 87b3be26a0f471f59b70af9f25b314722c76c7c9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 11:48:32 -0400 Subject: [PATCH 25/58] Closes #9434: Enabled django-rich test runner for more user-friendly output --- base_requirements.txt | 6 +++++- docs/release-notes/version-3.3.md | 1 + netbox/netbox/settings.py | 2 ++ requirements.txt | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..10e8af3ba 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 63fd9731f..514a92e88 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ ### Other Changes * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fd3730e2c..f9f4728a9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -423,6 +423,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( diff --git a/requirements.txt b/requirements.txt index 293a33542..d6ea22e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich-1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 From 9b51c2a0b2189c10c4ecfd10505a7f11558f5219 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 12:45:48 -0400 Subject: [PATCH 26/58] Fix typo --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6ea22e7d..1def8e23e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 -django-rich-1.4.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 From d1aa820856f0b80adffe8ea79f9a460589013fe3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 10 Jun 2022 23:13:49 +0200 Subject: [PATCH 27/58] Add configuration option JINJA2_FILTERS --- docs/configuration/optional-settings.md | 14 ++++++++++++++ netbox/netbox/settings.py | 1 + netbox/utilities/utils.py | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 670cf524b..0a7c7b6e0 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -255,6 +255,20 @@ HTTP_PROXIES = { --- +## JINJA2_FILTERS + +Default: `{}` + +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: + +```python +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + ## INTERNAL_IPS Default: `('127.0.0.1', '::1')` diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 16c2b8b6e..f30dea4d7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) +JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 7b37c0b70..bc6d928ed 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -14,6 +14,7 @@ from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable +from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY @@ -257,7 +258,9 @@ def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - return SandboxedEnvironment().from_string(source=template_code).render(**context) + environment = SandboxedEnvironment() + environment.filters.update(get_config().JINJA2_FILTERS) + return environment.from_string(source=template_code).render(**context) def prepare_cloned_fields(instance): From f13b090b5ca8cadc064b2985418b397267f64d9c Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Mon, 13 Jun 2022 07:56:31 +0200 Subject: [PATCH 28/58] Add distinct to Site search to prevent duplicates when search matches ASN --- netbox/dcim/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d57d0a59b..f052a8be9 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): From 8ef74192ec78596788f425ac76bb162510569ea3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 13 Jun 2022 20:45:08 +0200 Subject: [PATCH 29/58] Implement a custom paginator for DeviceViewSet Running count on the annotated query for loading config_context is slow. The custom paginator removes the annotation before getting the count. --- netbox/dcim/api/views.py | 2 ++ netbox/netbox/api/pagination.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e99ef333a..c4c25f654 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata +from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model @@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filtersets.DeviceFilterSet + pagination_class = StripCountAnnotationsPaginator def get_serializer_class(self): """ diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d89e32124..5ecade264 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): if isinstance(queryset, QuerySet): - self.count = queryset.count() + self.count = self.get_queryset_count(queryset) else: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) @@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return self.default_limit + def get_queryset_count(self, queryset): + return queryset.count() + def get_next_link(self): # Pagination has been disabled @@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return None return super().get_previous_link() + + +class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination): + """ + Strips the annotations on the queryset before getting the count + to optimize pagination of complex queries. + """ + def get_queryset_count(self, queryset): + # Clone the queryset to avoid messing up the actual query + cloned_queryset = queryset.all() + cloned_queryset.query.annotations.clear() + + return cloned_queryset.count() From 86c35a403a6e8656780be359e7b6acc7f8dc2231 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Jun 2022 19:05:16 -0400 Subject: [PATCH 30/58] Changelog for #9501, #9512 --- docs/configuration/optional-settings.md | 5 ++++- docs/release-notes/version-3.2.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 0a7c7b6e0..3b1c848a7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -259,9 +259,12 @@ HTTP_PROXIES = { Default: `{}` -A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: ```python +def uppercase(x): + return str(x).upper() + JINJA2_FILTERS = { 'uppercase': uppercase, } diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 339081902..93ca4a840 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters ### Bug Fixes @@ -13,6 +14,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN --- From 83fdfaa0eb836cdcf9bb6d78c138a274aa54cb1c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Jun 2022 19:14:29 -0400 Subject: [PATCH 31/58] Fixes #9524: Correct order of VLAN fields under VM interface creation form --- docs/release-notes/version-3.2.md | 1 + netbox/virtualization/forms/models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 93ca4a840..05d798e54 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -15,6 +15,7 @@ * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN +* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form --- diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..d2ebe5345 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -307,7 +307,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), From e8b970608eda6b6d1f8596cbcd457492f372d73d Mon Sep 17 00:00:00 2001 From: Kim Johansson Date: Wed, 15 Jun 2022 22:33:21 +0200 Subject: [PATCH 32/58] Replace None in templates with placeholder filter To be consistent, all uses of — or None is replaced with the placeholder filter. Fixes #9537 --- .../circuits/circuit_terminations_swap.html | 4 ++-- .../templates/circuits/inc/circuit_termination.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/cable.html | 4 ++-- netbox/templates/dcim/device.html | 12 ++++++------ netbox/templates/dcim/devicerole.html | 2 +- netbox/templates/dcim/devicetype.html | 4 ++-- netbox/templates/dcim/interface.html | 8 ++++---- netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/rack.html | 8 ++++---- netbox/templates/dcim/site.html | 10 +++++----- netbox/templates/dcim/virtualchassis_edit.html | 2 +- netbox/templates/extras/customfield.html | 4 ++-- netbox/templates/extras/htmx/report_result.html | 2 +- netbox/templates/generic/bulk_import.html | 4 ++-- netbox/templates/inc/panels/custom_fields.html | 2 +- netbox/templates/ipam/ipaddress.html | 6 +++--- netbox/templates/ipam/prefix.html | 8 ++++---- netbox/templates/ipam/role.html | 4 ++-- netbox/templates/ipam/service.html | 2 +- netbox/templates/ipam/vlan.html | 4 ++-- netbox/templates/tenancy/contact.html | 4 ++-- netbox/templates/users/profile.html | 2 +- netbox/templates/virtualization/virtualmachine.html | 8 ++++---- .../wireless/inc/wirelesslink_interface.html | 4 ++-- 25 files changed, 57 insertions(+), 57 deletions(-) diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html index 27eebb3d8..b2b30d635 100644 --- a/netbox/templates/circuits/circuit_terminations_swap.html +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -10,7 +10,7 @@ {% if termination_a %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • @@ -18,7 +18,7 @@ {% if termination_z %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index fdb01e803..b673cd4a3 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -94,7 +94,7 @@ {% elif termination.port_speed %} {{ termination.port_speed|humanize_speed }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1bf63f2d5..60bf8cfbc 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -50,7 +50,7 @@ {% if object.portal_url %} {{ object.portal_url }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index f1cf986e6..cd171cbb3 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -40,7 +40,7 @@ {% if object.color %}   {% else %} - + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.length %} {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d075a801d..d3d6f03dc 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -23,7 +23,7 @@ {% endfor %} {{ object.site.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -40,7 +40,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.rack %} {{ object.rack }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -69,7 +69,7 @@ {% elif object.rack and object.device_type.u_height %} Not racked {% else %} - + {{ ''|placeholder }} {% endif %} @@ -180,7 +180,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -195,7 +195,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 288101c08..610c53071 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -54,7 +54,7 @@ {% if object.vm_role %} {{ virtualmachine_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index e717a48aa..bb3ec9d2e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,7 +55,7 @@ {{ object.front_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -67,7 +67,7 @@ {{ object.rear_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 358922730..c4cb8b72f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -321,7 +321,7 @@ {% if object.rf_channel_frequency %} {{ object.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -329,7 +329,7 @@ {% if peer.rf_channel_frequency %} {{ peer.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} @@ -340,7 +340,7 @@ {% if object.rf_channel_width %} {{ object.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -348,7 +348,7 @@ {% if peer.rf_channel_width %} {{ peer.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 777af5563..ed1f9a1cd 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -44,7 +44,7 @@ {% if object.connected_endpoint %} {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6574e9b74..42f6a8e99 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -53,7 +53,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.type %} {{ object.get_type_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -133,7 +133,7 @@ {% if object.outer_width %} {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -143,7 +143,7 @@ {% if object.outer_depth %} {{ object.outer_depth }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index c15cab468..ab04ea018 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -34,7 +34,7 @@ {% endfor %} {{ object.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -47,7 +47,7 @@ {% endfor %} {{ object.group|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -79,7 +79,7 @@ {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
    Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -94,7 +94,7 @@
    {{ object.physical_address|linebreaksbr }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -113,7 +113,7 @@ {{ object.latitude }}, {{ object.longitude }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 327f20531..275391c61 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -57,7 +57,7 @@ {% if device.rack %} {{ device.rack }} / {{ device.position }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ device.serial|placeholder }} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index e8c3df460..a5618c6db 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -57,7 +57,7 @@ {% if object.choices %} {{ object.choices|join:", " }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -105,7 +105,7 @@ {% if object.validation_regex %} {{ object.validation_regex }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index 9b3e9db5f..c20bf5fe2 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -57,7 +57,7 @@ {% elif obj %} {{ obj }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ message|markdown }} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 43e078826..1a85c3a21 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -76,14 +76,14 @@ Context: {% if field.required %} {% checkmark True true="Required" %} {% else %} - + {{ ''|placeholder }} {% endif %} {% if field.to_field_name %} {{ field.to_field_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..6d23c81aa 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -37,7 +37,7 @@ {% elif field.required %} Not defined {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index ab47c11af..d1d49e7cc 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -52,7 +52,7 @@ {% if object.role %} {{ object.get_role_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -73,7 +73,7 @@ {% endif %} {{ object.assigned_object|linkify }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -86,7 +86,7 @@ ({{ object.nat_inside.assigned_object.parent_object|linkify }}) {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index e2ba76a82..a47566ff7 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -39,7 +39,7 @@ {% if aggregate %} {{ aggregate.prefix }} ({{ aggregate.rir }}) {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -52,7 +52,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -65,7 +65,7 @@ {% endif %} {{ object.vlan|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -138,7 +138,7 @@ {{ first_available_ip }} {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 49570099d..a6ef2c6d4 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -45,7 +45,7 @@ {% if ipranges_count %} {{ ipranges_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} @@ -57,7 +57,7 @@ {% if vlans_count %} {{ vlans_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 71ea20fa5..47ae70dc9 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -44,7 +44,7 @@ {% for ipaddress in object.ipaddresses.all %} {{ ipaddress|linkify }}
    {% empty %} - None + {{ ''|placeholder }} {% endfor %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index f74149ad6..fd0ba36a3 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -21,7 +21,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -56,7 +56,7 @@ {% if object.role %} {{ object.role }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index f55e87895..8e71628e9 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -35,7 +35,7 @@ {% if object.phone %} {{ object.phone }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -45,7 +45,7 @@ {% if object.email %} {{ object.email }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 112603126..913784c94 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -21,7 +21,7 @@ {% if request.user.first_name or request.user.last_name %} {{ request.user.first_name }} {{ request.user.last_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..61f9aa61a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -49,7 +49,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -64,7 +64,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.memory %} {{ object.memory|humanize_megabytes }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -125,7 +125,7 @@ {% if object.disk %} {{ object.disk }} GB {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index db4f84f0a..7732816a7 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -33,7 +33,7 @@ {% if interface.rf_channel_frequency %} {{ interface.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} @@ -43,7 +43,7 @@ {% if interface.rf_channel_width %} {{ interface.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} From cf76d5c46af8a68a7d29497ef28d43ae2ab83966 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 16 Jun 2022 22:26:37 +0200 Subject: [PATCH 33/58] Move markdown documentation to docs --- docs/reference/markdown.md | 353 ++++++++++++++++++++++++ mkdocs.yml | 1 + netbox/utilities/forms/fields/fields.py | 6 +- 3 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 docs/reference/markdown.md diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md new file mode 100644 index 000000000..896d5dcf7 --- /dev/null +++ b/docs/reference/markdown.md @@ -0,0 +1,353 @@ +--- +hide: + - toc +--- + +# Markdown + +NetBox supports markdown rendering for certain text fields. + +## Syntax + +##### Table of Contents +[Headers](#headers) +[Emphasis](#emphasis) +[Lists](#lists) +[Links](#links) +[Images](#images) +[Code Blocks](#code) +[Tables](#tables) +[Blockquotes](#blockquotes) +[Inline HTML](#html) +[Horizontal Rule](#hr) +[Line Breaks](#lines) + + + +## Headers + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + + + +## Emphasis + +```no-highlight +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ +``` + +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ + + + + +## Lists + +(In this example, leading and trailing spaces are shown with with dots: ⋅) + +```no-highlight +1. First ordered list item +2. Another item +⋅⋅* Unordered sub-list. +1. Actual numbers don't matter, just that it's a number +⋅⋅1. Ordered sub-list +4. And another item. + +⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + +⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅ +⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅ +⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses +``` + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + + You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + + To have a line break without a paragraph, you will need to use two trailing spaces. + Note that this line is separate, but within the same paragraph. + (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses + + + +## Links + +There are two ways to create links. + +```no-highlight +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com +``` + +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com + + + +## Images + +``` +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" +``` + +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" + + + +## Code blocks + +``` +Inline `code` has `back-ticks around` it. +``` + +Inline `code` has `back-ticks around` it. + +Blocks of code are fenced by lines with three back-ticks ``` + +```` +``` +var s = "Code block"; +alert(s); +``` +```` + +``` +var s = "Code block"; +alert(s); +``` + + + +## Tables + +```no-highlight +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. +The outer pipes (|) are optional, and you don't need to make the +raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 +``` + +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 + + + +## Blockquotes + +```no-highlight +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. +``` + +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + + + +## Inline HTML + +You can also use raw HTML in your Markdown, and it'll mostly work pretty well. + +```no-highlight +
    +
    Definition list
    +
    Is something people use sometimes.
    + +
    Markdown in HTML
    +
    Does *not* work **very** well. Use HTML tags.
    +
    +``` + +
    +
    Definition list
    +
    Is something people use sometimes.
    + +
    Markdown in HTML
    +
    Does *not* work **very** well. Use HTML tags.
    +
    + + + +## Horizontal Rule + +``` +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores +``` + +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores + + + +## Line Breaks + + +``` +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +``` + +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also begins a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 5c973e0d6..507b25627 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Overview: 'graphql-api/overview.md' - Reference: - Conditions: 'reference/conditions.md' + - Markdown: 'reference/markdown.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index 0d09d2ac7..9168189a1 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -3,6 +3,7 @@ import json from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.templatetags.static import static from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -26,10 +27,9 @@ class CommentField(forms.CharField): A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ widget = forms.Textarea - # TODO: Port Markdown cheat sheet to internal documentation - help_text = """ + help_text = f""" - + Markdown syntax is supported """ From 896ebf01b1dbc60dc68e3036940793ec28ebadab Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:04:57 -0400 Subject: [PATCH 34/58] Changelog for #8704, #9533, #9374, #9466, #9537 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 05d798e54..93021320f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,18 +4,23 @@ ### Enhancements +* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation ### Bug Fixes +* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data +* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form +* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI --- From 2815eca260acba39486f1c027ff09ef543834e11 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:36:55 -0400 Subject: [PATCH 35/58] Fixes #9503: Hyperlinks in ack elevation SVGs must always use absolute URLs --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/svg.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 93021320f..e69843198 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 7cd0fa417..1de68ec36 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -114,7 +114,7 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: image = drawing.image( - href=device.device_type.front_image.url, + href='{}{}'.format(self.base_url, device.device_type.front_image.url), insert=start, size=end, class_='device-image' @@ -140,7 +140,7 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: image = drawing.image( - href=device.device_type.rear_image.url, + href='{}{}'.format(self.base_url, device.device_type.rear_image.url), insert=start, size=end, class_='device-image' @@ -151,9 +151,9 @@ class RackElevationSVG: stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): - link_url = '{}?{}'.format( + def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): + link_url = '{}{}?{}'.format( + self.base_url, reverse('dcim:device_add'), urlencode({ 'site': rack.site.pk, From 92a6523bf38babdfc4380bf900849f5cb4500e38 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:40:37 -0400 Subject: [PATCH 36/58] Fixes #9549: Fix device counts for rack list under rack role view --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e69843198..4e96145e0 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -22,6 +22,7 @@ * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI +* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 57e8b1c79..35a1056b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - racks = Rack.objects.restrict(request.user, 'view').filter( - role=instance + racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate( + device_count=count_related(Device, 'rack') ) racks_table = tables.RackTable(racks, user=request.user, exclude=( From e6018cd38fd3d186e52bc926e2891f412ca4bb66 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:51:45 -0400 Subject: [PATCH 37/58] Closes #9534: Add VLAN group selector to interface bulk edit forms --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/bulk_edit.py | 31 +++++++++++++++++++----- netbox/virtualization/forms/bulk_edit.py | 21 +++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 4e96145e0..40715c8d3 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -9,6 +9,7 @@ * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation +* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms ### Bug Fixes diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9e4f5e400..231d01ddd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, VLAN, VRF +from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm( required=False, widget=BulkEditNullBooleanSelect ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm( ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - 'vrf', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', + 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..4894d78cf 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,7 +3,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import VLAN, VRF +from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -182,13 +182,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -200,7 +213,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( (None, ('mtu', 'enabled', 'vrf', 'description')), ('Related Interfaces', ('parent', 'bridge')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ) nullable_fields = ( 'parent', 'bridge', 'mtu', 'vrf', 'description', From a6e285316aa2a873c642081442edf1ebce815b68 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 17 Jun 2022 22:53:51 +0200 Subject: [PATCH 38/58] Don't close select field when multiple select --- netbox/project-static/dist/netbox.js | Bin 376088 -> 376144 bytes netbox/project-static/dist/netbox.js.map | Bin 345522 -> 345564 bytes .../src/select/api/apiSelect.ts | 5 +++++ 3 files changed, 5 insertions(+) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ce02d4bbb227926941d2adc3ae38e721d48ce21b..bc0cabef08f2cab679e475fe18741c4e97ee187e 100644 GIT binary patch delta 60 zcmbR7OYFigv4$4L7N!>F7M3lnkK81a5{pyya!YecG7EB2)zmafGBS(xigPk^r!$(c QiZa_O8g9Ss#%fyz0LEk$X#fBK delta 25 hcmcccOKiq3v4$4L7N!>F7M3lnkKDFPxwD#;0RWK~3C#ci diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index e21571e0c84f1680154cb438612199fef77a0f4e..26bb1c514297c61a2e7d50a8863067ed8656e455 100644 GIT binary patch delta 101 zcmdngEqbS0w4sHug{g&k3ybc`Glu|1!MHP;dVu00d@ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 88b35a0e9..f5b605d58 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -205,6 +205,11 @@ export class APISelect { onChange: () => this.handleSlimChange(), }); + // Don't close on select if multiple select + if (this.base.multiple) { + this.slim.config.closeOnSelect = false; + } + // Initialize API query properties. this.getStaticParams(); this.getDynamicParams(); From 7c79c90cd2403d15f30a74a97892c5237ea213d4 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 17 Jun 2022 23:16:57 +0200 Subject: [PATCH 39/58] Sanitize HTML after rendering markdown --- base_requirements.txt | 4 +++ .../templatetags/builtins/filters.py | 19 ++++-------- netbox/utilities/utils.py | 31 +++++++++++++++++++ requirements.txt | 1 + 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..68fca9851 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -125,3 +125,7 @@ tablib # Timezone data (required by django-timezone-field on Python 3.9+) # https://github.com/python/tzdata tzdata + +# HTML sanitizer +# https://github.com/mozilla/bleach +bleach \ No newline at end of file diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 44ad5ac47..738dc0e00 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import clean_html, foreground_color register = template.Library() @@ -144,18 +144,6 @@ def render_markdown(value): {{ md_source_text|markdown }} """ - schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) - - # Strip HTML tags - value = strip_tags(value) - - # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' - value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) - - # Sanitize Markdown reference links - pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' - value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) @@ -164,6 +152,11 @@ def render_markdown(value): if html: html = f'
    {html}
    ' + schemes = get_config().ALLOWED_URL_SCHEMES + + # Sanitize HTML + html = clean_html(html, schemes) + return mark_safe(html) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index bc6d928ed..2b939471c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -4,6 +4,7 @@ from collections import OrderedDict from decimal import Decimal from itertools import count, groupby +import bleach from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce @@ -385,3 +386,33 @@ def copy_safe_request(request): 'path': request.path, 'id': getattr(request, 'id', None), # UUID assigned by middleware }) + + +def clean_html(html, schemes): + """ + Sanitizes HTML based on a whitelist of allowed tags and attributes. + Also takes a list of allowed URI schemes. + """ + + ALLOWED_TAGS = [ + "div", "pre", "code", "blockquote", "del", + "hr", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "p", "br", + "strong", "em", "a", "b", "i", "img", + "table", "thead", "tbody", "tr", "th", "td", + "dl", "dt", "dd", + ] + + ALLOWED_ATTRIBUTES = { + "div": ['class'], + "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"], + "a": ["href", "title"], + "img": ["src", "title", "alt"], + } + + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=schemes + ) diff --git a/requirements.txt b/requirements.txt index 293a33542..dbe7d70c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +bleach==5.0.0 Django==4.0.4 django-cors-headers==3.12.0 django-debug-toolbar==3.2.4 From 3d785d836d1cc9f9e84ec305b80669e22d47ef19 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jun 2022 23:05:18 -0400 Subject: [PATCH 40/58] Implemented feature #9525 --- netbox/netbox/tables/tables.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..b2bf6e967 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -165,7 +165,14 @@ class NetBoxTable(BaseTable): linkify=True, verbose_name='ID' ) - actions = columns.ActionsColumn() + actions = columns.ActionsColumn( + extra_buttons=""" + + + + + """ + ) exempt_columns = ('pk', 'actions') From b1ec703ba9dd254b8a7edd5f0c294de56323e495 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jun 2022 23:08:06 -0400 Subject: [PATCH 41/58] Implemented feature #9525 --- netbox/netbox/tables/tables.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index b2bf6e967..de73bf6fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -167,10 +167,7 @@ class NetBoxTable(BaseTable): ) actions = columns.ActionsColumn( extra_buttons=""" - - - - + """ ) From ff2ccfd6707b895be5fbeb602dc969700eda17c3 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 19:12:52 -0400 Subject: [PATCH 42/58] Closes #9525: Added split button functionality to ActionsColumn --- netbox/netbox/tables/columns.py | 43 ++++++++++++++++++++++++++------- netbox/netbox/tables/tables.py | 6 +---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index e82e8a1ea..9067b13f2 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -175,6 +175,7 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () @@ -184,10 +185,11 @@ class ActionsColumn(tables.Column): 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs): super().__init__(*args, **kwargs) self.extra_buttons = extra_buttons + self.split_actions = split_actions # Determine which actions to enable self.actions = { @@ -210,19 +212,42 @@ class ActionsColumn(tables.Column): # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) - for action, attrs in self.actions.items(): + for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append( - f'
  • ' - f' {attrs.title}
  • ' - ) + + # If only a single action exists, render a regular button + if len(self.actions.items()) == 1: + html += ( + f'' + f'' + ) + + # Creates split button for the first action with direct link and icon + elif self.split_actions and idx == 0: + html += ( + f'' + f'' + f'' + ) + + # Creates standard action dropdown menu items + else: + links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + + # Create the actions dropdown menu if links: + dropdown_icon = '' if self.split_actions else '' + dropdown_class = '' if self.split_actions else '' html += ( - f'' - f'' - f'' + f'{dropdown_class}' + f'' + f'{dropdown_icon}' + f'Toggle Dropdown' f'' ) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index de73bf6fe..8c5fb039c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -165,11 +165,7 @@ class NetBoxTable(BaseTable): linkify=True, verbose_name='ID' ) - actions = columns.ActionsColumn( - extra_buttons=""" - - """ - ) + actions = columns.ActionsColumn() exempt_columns = ('pk', 'actions') From 65683d0df1bfb9da5ab904937acfe1ba84302e3d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 20:00:15 -0400 Subject: [PATCH 43/58] Closes #9417: Pre-populate manufacturer when adding modules to devices --- netbox/dcim/tables/template_code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 92739c6ed..7124c2b1f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} From e7620b0dd08b1f9ce16874a10e0e0accbf5a9284 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 22:10:01 -0400 Subject: [PATCH 44/58] Closes #9517: Linkify Power Port on Power Outlet Object View --- netbox/templates/dcim/poweroutlet.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 6408bc759..c312bee03 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,7 +44,7 @@ Power Port - {{ object.power_port }} + {{ object.power_port|linkify|placeholder }} Feed Leg From 10cb4f359a837eeca167b34e8a672a2c632a8902 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 08:06:49 -0400 Subject: [PATCH 45/58] Changelog for #9417, #9517, #9525 --- docs/release-notes/version-3.2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 40715c8d3..66a9cdf66 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -7,7 +7,10 @@ * [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view +* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation * [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms From 903a3e1a9c162953389797d48106f8e2b7e1400f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 08:34:05 -0400 Subject: [PATCH 46/58] Changelog for #8944, #9108, #9556 --- docs/release-notes/version-3.2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 66a9cdf66..a491f4524 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -13,9 +13,12 @@ * [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation * [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms +* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields ### Bug Fixes +* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons +* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links * [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data * [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers From d691ea92d09be00b6390caef1c9aff9705b411ce Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 09:44:59 -0400 Subject: [PATCH 47/58] #9525: Add button colors --- netbox/netbox/tables/columns.py | 58 ++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9067b13f2..7da241566 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -166,6 +166,7 @@ class ActionsItem: title: str icon: str permission: Optional[str] = None + css_class: Optional[str] = 'secondary' class ActionsColumn(tables.Column): @@ -175,13 +176,14 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown - :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the direct button link and icon (default: True) + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the + direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { - 'edit': ActionsItem('Edit', 'pencil', 'change'), - 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 'changelog': ActionsItem('Changelog', 'history'), } @@ -210,45 +212,49 @@ class ActionsColumn(tables.Column): html = '' # Compile actions menu - links = [] + button = None + dropdown_class = 'secondary' + dropdown_links = [] user = getattr(request, 'user', AnonymousUser()) for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - # If only a single action exists, render a regular button - if len(self.actions.items()) == 1: - html += ( - f'' + # Render a separate button if a) only one action exists, or b) if split_actions is True + if len(self.actions) == 1 or (self.split_actions and idx == 0): + dropdown_class = attrs.css_class + button = ( + f'' f'' ) - # Creates split button for the first action with direct link and icon - elif self.split_actions and idx == 0: - html += ( - f'' - f'' - f'' - ) - - # Creates standard action dropdown menu items + # Add dropdown menu items else: - links.append( + dropdown_links.append( f'
  • ' f' {attrs.title}
  • ' ) # Create the actions dropdown menu - if links: - dropdown_icon = '' if self.split_actions else '' - dropdown_class = '' if self.split_actions else '' + if button and dropdown_links: html += ( - f'{dropdown_class}' - f'' - f'{dropdown_icon}' - f'Toggle Dropdown' - f'' + f'' + f' {button}' + f' ' + f' Toggle Dropdown' + f' ' + f'' + ) + elif button: + html += button + elif dropdown_links: + html += ( + f'' + f' ' + f' Toggle Dropdown' + f' ' + f'' ) # Render any extra buttons from template code From 8074ca95bd279169dc8283fdc4e2621fd02eab9a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:17:15 -0400 Subject: [PATCH 48/58] Closes #9453: Disable default loggers when running tests --- netbox/netbox/configuration_testing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 59529b80c..621671f04 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -36,3 +36,8 @@ REDIS = { } SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True +} From 7ba268946a5678477cd22ae05cfe8e3f4ccd8950 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:22:36 -0400 Subject: [PATCH 49/58] Release v3.2.5 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 3 ++- docs/release-notes/version-3.2.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 12 ++++++------ 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a9af9c653..3b87a49e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1fff99f1d..1fc0268ab 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 68fca9851..98d3f78c2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -44,7 +44,8 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -django-taggit +# Will evaluate v3.0 during NetBox v3.3 beta +django-taggit>=2.1.0,<3.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index a491f4524..bb5182702 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.5 (FUTURE) +## v3.2.5 (2022-06-20) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f30dea4d7..f86f3a8f2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.5-dev' +VERSION = '3.2.5' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index dbe7d70c2..1fdace4f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ bleach==5.0.0 -Django==4.0.4 -django-cors-headers==3.12.0 -django-debug-toolbar==3.2.4 -django-filter==21.1 +Django==4.0.5 +django-cors-headers==3.13.0 +django-debug-toolbar==3.4.0 +django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 @@ -19,7 +19,7 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.16 +mkdocs-material==8.3.6 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.1.1 @@ -27,7 +27,7 @@ psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 social-auth-app-django==5.0.0 -social-auth-core==4.2.0 +social-auth-core==4.3.0 svgwrite==1.4.2 tablib==3.2.1 tzdata==2022.1 From 575e2c443bf19e427c1f229f97eaa8a16d141e61 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:38:49 -0400 Subject: [PATCH 50/58] PRVB --- docs/release-notes/version-3.2.md | 6 +++++- netbox/netbox/settings.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index bb5182702..059fc8924 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.6 (FUTURE) + +--- + ## v3.2.5 (2022-06-20) ### Enhancements @@ -25,7 +29,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column -* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f86f3a8f2..b2e1eca6c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.5' +VERSION = '3.2.6-dev' # Hostname HOSTNAME = platform.node() From 84f056171286d18c1c14a2fc9d28155a7dcf169a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 17:27:58 -0400 Subject: [PATCH 51/58] Initial work on half-height RUs --- docs/release-notes/version-3.3.md | 11 ++ netbox/dcim/api/serializers.py | 24 ++- netbox/dcim/forms/models.py | 2 +- .../migrations/0154_half_height_rack_units.py | 23 +++ netbox/dcim/models/devices.py | 12 +- netbox/dcim/models/racks.py | 56 +++--- netbox/dcim/svg.py | 175 ++++++++++-------- netbox/dcim/tests/test_api.py | 8 +- netbox/dcim/tests/test_models.py | 31 +++- netbox/utilities/forms/utils.py | 1 - netbox/utilities/utils.py | 16 ++ 11 files changed, 232 insertions(+), 127 deletions(-) create mode 100644 netbox/dcim/migrations/0154_half_height_rack_units.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 514a92e88..229509b9c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,8 +4,13 @@ ### Breaking Changes +* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). +### New Features + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -23,6 +28,12 @@ ### REST API Changes +* dcim.Device + * The `position` field has been changed from an integer to a decimal +* dcim.DeviceType + * The `u_height` field has been changed from an integer to a decimal +* dcim.Rack + * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField * Added `group_name` and `ui_visibility` fields * ipam.IPAddress diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..ba7f661b5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,5 @@ +import decimal + from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -201,7 +203,11 @@ class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. """ - id = serializers.IntegerField(read_only=True) + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -283,6 +289,13 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=1.0 + ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -589,7 +602,14 @@ class DeviceSerializer(NetBoxModelSerializer): location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') - position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=None + ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..fe461b061 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'location_id': '$location', } ) - position = forms.IntegerField( + position = forms.DecimalField( required=False, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e88af2d05..14147f388 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -99,8 +99,10 @@ class DeviceType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - u_height = models.PositiveSmallIntegerField( - default=1, + u_height = models.DecimalField( + max_digits=4, + decimal_places=1, + default=1.0, verbose_name='Height (U)' ) is_full_depth = models.BooleanField( @@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel): blank=True, null=True ) - position = models.PositiveSmallIntegerField( + position = models.DecimalField( + max_digits=4, + decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 81d699b11..f963fb396 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +import decimal from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation @@ -13,11 +13,10 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.config import get_config from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string +from utilities.utils import array_to_string, drange from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -242,10 +241,13 @@ class Rack(NetBoxModel): @property def units(self): + """ + Return a list of unit numbers, top to bottom. + """ + max_position = self.u_height + decimal.Decimal(0.5) if self.desc_units: - return range(1, self.u_height + 1) - else: - return reversed(range(1, self.u_height + 1)) + drange(0.5, max_position, 0.5) + return drange(max_position, 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -263,12 +265,12 @@ class Rack(NetBoxModel): reference to the device. When False, only the bottom most unit for a device is included and that unit contains a height attribute for the device """ - - elevation = OrderedDict() + elevation = {} for u in self.units: + u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}' elevation[u] = { 'id': u, - 'name': f'U{u}', + 'name': u_name, 'face': face, 'device': None, 'occupied': False @@ -278,7 +280,7 @@ class Rack(NetBoxModel): if self.pk: # Retrieve all devices installed within the rack - queryset = Device.objects.prefetch_related( + devices = Device.objects.prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -299,9 +301,9 @@ class Rack(NetBoxModel): if user is not None: permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True) - for device in queryset: + for device in devices: if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): + for u in drange(device.position, device.position + device.device_type.u_height, 0.5): if user is None or device.pk in permitted_device_ids: elevation[u]['device'] = device elevation[u]['occupied'] = True @@ -310,8 +312,6 @@ class Rack(NetBoxModel): elevation[device.position]['device'] = device elevation[device.position]['occupied'] = True elevation[device.position]['height'] = device.device_type.u_height - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) return [u for u in elevation.values()] @@ -331,12 +331,12 @@ class Rack(NetBoxModel): devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) + units = list(self.units) # Remove units consumed by installed devices for d in devices: if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: - for u in range(d.position, d.position + d.device_type.u_height): + for u in drange(d.position, d.position + d.device_type.u_height, 0.5): try: units.remove(u) except ValueError: @@ -346,7 +346,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(range(u, u + u_height)).issubset(units): + if set(drange(u, u + u_height, 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -356,9 +356,9 @@ class Rack(NetBoxModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): - for u in r.units: - reserved_units[u] = r + for reservation in self.reservations.all(): + for u in reservation.units: + reserved_units[u] = reservation return reserved_units def get_elevation_svg( @@ -384,13 +384,17 @@ class Rack(NetBoxModel): :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ - elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) - if unit_width is None or unit_height is None: - config = get_config() - unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + elevation = RackElevationSVG( + self, + unit_width=unit_width, + unit_height=unit_height, + legend_width=legend_width, + user=user, + include_images=include_images, + base_url=base_url + ) - return elevation.render(face, unit_width, unit_height, legend_width) + return elevation.render(face) def get_0u_devices(self): return self.devices.filter(position=0) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 1de68ec36..dfb788e38 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -1,3 +1,4 @@ +import decimal import svgwrite from svgwrite.container import Group, Hyperlink from svgwrite.shapes import Line, Rect @@ -7,6 +8,7 @@ from django.conf import settings from django.urls import reverse from django.utils.http import urlencode +from netbox.config import get_config from utilities.utils import foreground_color from .choices import DeviceFaceChoices from .constants import RACK_ELEVATION_BORDER_WIDTH @@ -36,13 +38,17 @@ class RackElevationSVG: :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, user=None, include_images=True, base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, + base_url=None): self.rack = rack self.include_images = include_images - if base_url is not None: - self.base_url = base_url.rstrip('/') - else: - self.base_url = '' + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -78,15 +84,16 @@ class RackElevationSVG: gradient.add_stop_color(offset='100%', color=color) drawing.defs.add(gradient) - @staticmethod - def _setup_drawing(width, height): + def _setup_drawing(self): + width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) - # add the stylesheet + # Add the stylesheet with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: drawing.defs.add(drawing.style(css_file.read())) - # add gradients + # Add gradients RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') @@ -151,7 +158,7 @@ class RackElevationSVG: stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): + def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation): link_url = '{}{}?{}'.format( self.base_url, reverse('dcim:device_add'), @@ -160,7 +167,7 @@ class RackElevationSVG: 'location': rack.location.pk if rack.location else '', 'rack': rack.pk, 'face': face_id, - 'position': id_ + 'position': unit }) ) link = drawing.add( @@ -173,98 +180,108 @@ class RackElevationSVG: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def merge_elevations(self, face): - elevation = self.rack.get_rack_units(face=face, expand_devices=False) - if face == DeviceFaceChoices.FACE_REAR: - other_face = DeviceFaceChoices.FACE_FRONT - else: - other_face = DeviceFaceChoices.FACE_REAR - other = self.rack.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def render(self, face, unit_width, unit_height, legend_width): + def draw_legend(self): """ - Return an SVG document representing a rack elevation. + Draw the rack unit labels along the lefthand side of the elevation. """ - drawing = self._setup_drawing( - unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, - unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - ) - reserved_units = self.rack.get_reserved_units() - - unit_cursor = 0 for ru in range(0, self.rack.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + start_y = ru * self.unit_height + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") + self.drawing.add( + Text(str(unit), position_coordinates, class_="unit") ) - for unit in self.merge_elevations(face): + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): # Loop through all units in the elevation device = unit['device'] - height = unit.get('height', 1) + height = unit.get('height', decimal.Decimal(1.0)) # Setup drawing coordinates - x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH - y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH - end_y = unit_height * height + x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y_offset = int(unit['id'] * self.unit_height) + RACK_ELEVATION_BORDER_WIDTH + else: + y_offset = self.drawing['height'] - int(unit['id'] * self.unit_height) - RACK_ELEVATION_BORDER_WIDTH + + end_y = int(self.unit_height * height) start_cordinates = (x_offset, y_offset) - end_cordinates = (unit_width, end_y) - text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) + size = (self.unit_width, end_y) + text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2) # Draw the device - if device and device.face == face and device.pk in self.permitted_device_ids: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + if device and device.pk in self.permitted_device_ids: + print(device) + print(f' {start_cordinates}') + print(f' {size}') + + if device.face == face and not opposite: + self._draw_device_front(self.drawing, device, start_cordinates, size, text_cordinates) + else: + self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates) + elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, - self.rack, - start_cordinates, - end_cordinates, - text_cordinates, - unit["id"], - face, - class_, - reservation - ) + self.drawing.add(Rect(start_cordinates, size, class_='blocked')) - unit_cursor += height + # else: + # # Draw shallow devices, reservations, or empty units + # class_ = 'slot' + # # reservation = reserved_units.get(unit["id"]) + # reservation = None + # if device: + # class_ += ' occupied' + # if reservation: + # class_ += ' reserved' + # self._draw_empty( + # self.drawing, + # self.rack, + # start_cordinates, + # end_cordinates, + # text_cordinates, + # unit["id"], + # face, + # class_, + # reservation + # ) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # reserved_units = self.rack.get_reserved_units() + + # Draw the unit legend + self.draw_legend() + + # Draw the opposite rack face first, then the near face + if face == DeviceFaceChoices.FACE_REAR: + opposite_face = DeviceFaceChoices.FACE_FRONT + else: + opposite_face = DeviceFaceChoices.FACE_REAR + # self.draw_face(opposite_face, opposite=True) + self.draw_face(face) # Wrap the drawing with a border border_width = RACK_ELEVATION_BORDER_WIDTH border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = drawing.rect( - insert=(legend_width + border_offset, border_offset), - size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), class_='rack' ) - drawing.add(frame) + self.drawing.add(frame) - return drawing + return self.drawing OFFSET = 0.5 diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22537abe0..a6631208b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase): # Retrieve all units response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 42) + self.assertEqual(response.data['count'], 84) # Search for specific units response = self.client.get(f'{url}?q=3', **self.header) - self.assertEqual(response.data['count'], 13) + self.assertEqual(response.data['count'], 26) response = self.client.get(f'{url}?q=U3', **self.header) - self.assertEqual(response.data['count'], 11) + self.assertEqual(response.data['count'], 22) response = self.client.get(f'{url}?q=U10', **self.header) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['count'], 2) def test_get_rack_elevation_svg(self): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 8566f969b..eefef3fb4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,3 +1,5 @@ +import decimal + from django.core.exceptions import ValidationError from django.test import TestCase @@ -5,6 +7,7 @@ from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant +from utilities.utils import drange class LocationTestCase(TestCase): @@ -183,26 +186,34 @@ class RackTestCase(TestCase): device_role=DeviceRole.objects.get(slug='switch'), site=self.site1, rack=self.rack, - position=10, + position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) + self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) - self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del(rack1_inventory_front[-10]) - for u in rack1_inventory_front: + rack1_inventory_front = { + u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + } + self.assertEqual(rack1_inventory_front[10.0]['device'], device1) + self.assertEqual(rack1_inventory_front[10.5]['device'], device1) + del(rack1_inventory_front[10.0]) + del(rack1_inventory_front[10.5]) + for u in rack1_inventory_front.values(): self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) - self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del(rack1_inventory_rear[-10]) - for u in rack1_inventory_rear: + rack1_inventory_rear = { + u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + } + self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) + self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) + del(rack1_inventory_rear[10.0]) + del(rack1_inventory_rear[10.5]) + for u in rack1_inventory_rear.values(): self.assertIsNone(u['device']) def test_mount_zero_ru(self): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,7 +1,6 @@ import re from django import forms -from django.conf import settings from django.forms.models import fields_for_model from utilities.choices import unpack_grouped_choices diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 2b939471c..6a1b560e1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,4 +1,5 @@ import datetime +import decimal import json from collections import OrderedDict from decimal import Decimal @@ -226,6 +227,21 @@ def deepmerge(original, new): return merged +def drange(start, end, step=decimal.Decimal(1)): + """ + Decimal-compatible implementation of Python's range() + """ + start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step) + if start < end: + while start < end: + yield start + start += step + else: + while start > end: + yield start + start += step + + def to_meters(length, unit): """ Convert the given length to meters. From 0c915f7de9612c7485da3713cc6d63f368698a5d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 14:54:18 -0400 Subject: [PATCH 52/58] Clean up rack elevation rendering --- netbox/dcim/svg.py | 167 ++++++++++++++++++++++----------------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index dfb788e38..95db476ad 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -94,18 +94,34 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing - def _draw_device_front(self, drawing, device, start, end, text): + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device_front(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) name = get_device_name(device) if device.devicebay_count: name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color + link = drawing.add( drawing.a( href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), @@ -114,25 +130,30 @@ class RackElevationSVG: ) ) link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) + link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text_coords, fill=hex_color)) # Embed front device type image if one exists if self.include_images and device.device_type.front_image: image = drawing.image( href='{}{}'.format(self.base_url, device.device_type.front_image.url), insert=start, - size=end, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', + link.add(drawing.text(str(name), insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) + link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) + + def _draw_device_rear(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) - def _draw_device_rear(self, drawing, device, start, end, text): link = drawing.add( drawing.a( href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), @@ -141,57 +162,76 @@ class RackElevationSVG: ) ) link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text)) + link.add(drawing.rect(start, size, class_="slot blocked")) + link.add(drawing.text(get_device_name(device), insert=text_coords)) # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: image = drawing.image( href='{}{}'.format(self.base_url, device.device_type.rear_image.url), insert=start, - size=end, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', + link.add(Text(get_device_name(device), insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) + link.add(Text(get_device_name(device), insert=text_coords, fill='white', class_='device-image-label')) - def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation): - link_url = '{}{}?{}'.format( - self.base_url, - reverse('dcim:device_add'), - urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': unit - }) + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' ) - link = drawing.add( - drawing.a(href=link_url, target='_top') - ) - if reservation: - link.set_desc('{} — {} · {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) + self.drawing.add(frame) def draw_legend(self): """ Draw the rack unit labels along the lefthand side of the elevation. """ for ru in range(0, self.rack.u_height): - start_y = ru * self.unit_height + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru self.drawing.add( - Text(str(unit), position_coordinates, class_="unit") + Text(str(unit), position_coordinates, class_='unit') ) + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 + ) + + link = Hyperlink(href=url_string.format(ru), target='_blank') + link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(self.drawing.text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + def draw_face(self, face, opposite=False): """ Draw any occupied rack units for the specified rack face. @@ -202,54 +242,21 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - # Setup drawing coordinates - x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH - if self.rack.desc_units: - y_offset = int(unit['id'] * self.unit_height) + RACK_ELEVATION_BORDER_WIDTH - else: - y_offset = self.drawing['height'] - int(unit['id'] * self.unit_height) - RACK_ELEVATION_BORDER_WIDTH - + start_cordinates = self._get_device_coords(unit['id'], height) end_y = int(self.unit_height * height) - start_cordinates = (x_offset, y_offset) size = (self.unit_width, end_y) - text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2) # Draw the device if device and device.pk in self.permitted_device_ids: - print(device) - print(f' {start_cordinates}') - print(f' {size}') - if device.face == face and not opposite: - self._draw_device_front(self.drawing, device, start_cordinates, size, text_cordinates) + self._draw_device_front(self.drawing, device, start_cordinates, size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates) + self._draw_device_rear(self.drawing, device, start_cordinates, size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space self.drawing.add(Rect(start_cordinates, size, class_='blocked')) - # else: - # # Draw shallow devices, reservations, or empty units - # class_ = 'slot' - # # reservation = reserved_units.get(unit["id"]) - # reservation = None - # if device: - # class_ += ' occupied' - # if reservation: - # class_ += ' reserved' - # self._draw_empty( - # self.drawing, - # self.rack, - # start_cordinates, - # end_cordinates, - # text_cordinates, - # unit["id"], - # face, - # class_, - # reservation - # ) - def render(self, face): """ Return an SVG document representing a rack elevation. @@ -258,10 +265,9 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # reserved_units = self.rack.get_reserved_units() - - # Draw the unit legend + # Draw the empty rack & legend self.draw_legend() + self.draw_background(face) # Draw the opposite rack face first, then the near face if face == DeviceFaceChoices.FACE_REAR: @@ -271,15 +277,8 @@ class RackElevationSVG: # self.draw_face(opposite_face, opposite=True) self.draw_face(face) - # Wrap the drawing with a border - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = Rect( - insert=(self.legend_width + border_offset, border_offset), - size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), - class_='rack' - ) - self.drawing.add(frame) + # Draw the rack border last + self.draw_border() return self.drawing From ae129485583afe89c9bfa51d301f10de3a96a37d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:00:39 -0400 Subject: [PATCH 53/58] Refactor device rendering methods --- netbox/dcim/svg.py | 153 ++++++++++++++++++++++----------------------- 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 95db476ad..0986e94d3 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -1,6 +1,8 @@ import decimal import svgwrite from svgwrite.container import Group, Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient from svgwrite.shapes import Line, Rect from svgwrite.text import Text @@ -22,11 +24,27 @@ __all__ = ( def get_device_name(device): if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' + name = f'{device.virtual_chassis.name}:{device.vc_position}' elif device.name: - return device.name + name = device.name else: - return str(device.device_type) + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) class RackElevationSVG: @@ -56,21 +74,9 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - @staticmethod - def _get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - @staticmethod def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( + gradient = LinearGradient( start=(0, 0), end=(0, 25), spreadMethod='repeat', @@ -82,6 +88,7 @@ class RackElevationSVG: gradient.add_stop_color(offset='50%', color='#f7f7f7') gradient.add_stop_color(offset='50%', color=color) gradient.add_stop_color(offset='100%', color=color) + drawing.defs.add(gradient) def _setup_drawing(self): @@ -90,7 +97,7 @@ class RackElevationSVG: drawing = svgwrite.Drawing(size=(width, height)) # Add the stylesheet - with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: drawing.defs.add(drawing.style(css_file.read())) # Add gradients @@ -112,72 +119,60 @@ class RackElevationSVG: return x, y - def _draw_device_front(self, drawing, device, start, size): - text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 - ) + def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color - - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text_coords, fill=hex_color)) - - # Embed front device type image if one exists - if self.include_images and device.device_type.front_image: - image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.front_image.url), - insert=start, - size=size, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) - - def _draw_device_rear(self, drawing, device, start, size): + description = get_device_description(device) text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 ) + text_color = f'#{foreground_color(color)}' if color else '#000000' - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text_coords)) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) - # Embed rear device type image if one exists - if self.include_images and device.device_type.rear_image: - image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.rear_image.url), - insert=start, + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(Text(get_device_name(device), insert=text_coords, stroke='black', + link.add(Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(get_device_name(device), insert=text_coords, fill='white', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) def draw_border(self): """ @@ -228,7 +223,7 @@ class RackElevationSVG: link = Hyperlink(href=url_string.format(ru), target='_blank') link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) - link.add(self.drawing.text('add device', insert=text_coords, class_='add-device')) + link.add(Text('add device', insert=text_coords, class_='add-device')) self.drawing.add(link) @@ -242,20 +237,22 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - start_cordinates = self._get_device_coords(unit['id'], height) - end_y = int(self.unit_height * height) - size = (self.unit_width, end_y) + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) # Draw the device if device and device.pk in self.permitted_device_ids: if device.face == face and not opposite: - self._draw_device_front(self.drawing, device, start_cordinates, size) + self.draw_device_front(device, device_coords, device_size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size) + self.draw_device_rear(device, device_coords, device_size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - self.drawing.add(Rect(start_cordinates, size, class_='blocked')) + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) def render(self, face): """ From 278891c262a49ef29079e2b4af186af265567555 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:14:51 -0400 Subject: [PATCH 54/58] Fix rack utilization calculation --- netbox/dcim/models/racks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index f963fb396..12cc4dd38 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -405,6 +405,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -412,8 +413,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage From 0d6d68c62fac1072b78ebfd91863b5405beb61a6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 21:28:57 -0400 Subject: [PATCH 55/58] Fix YAML representation of decimal values --- netbox/dcim/models/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 14147f388..43b84974b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -168,7 +168,7 @@ class DeviceType(NetBoxModel): ('model', self.model), ('slug', self.slug), ('part_number', self.part_number), - ('u_height', self.u_height), + ('u_height', float(self.u_height)), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), ('airflow', self.airflow), From 4ced0bed13a86b2f87788ed104944c5c1752e737 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 12:37:56 -0400 Subject: [PATCH 56/58] Clean up rack model tests --- netbox/dcim/tests/test_models.py | 166 ++++++++++++------------------- 1 file changed, 63 insertions(+), 103 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index eefef3fb4..da54fc98d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,5 +1,3 @@ -import decimal - from django.core.exceptions import ValidationError from django.test import TestCase @@ -77,126 +75,90 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() 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, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() 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=self.rack, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) rack1_inventory_front = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) } self.assertEqual(rack1_inventory_front[10.0]['device'], device1) self.assertEqual(rack1_inventory_front[10.5]['device'], device1) @@ -207,7 +169,7 @@ class RackTestCase(TestCase): # Validate inventory (rear face) rack1_inventory_rear = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) } self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) @@ -217,16 +179,17 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( + site = Site.objects.first() + rack = Rack.objects.first() + + device = Device.objects.create( name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack ) - self.assertTrue(pdu) + self.assertTrue(device) def test_change_rack_site(self): """ @@ -235,19 +198,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - device_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b From 103729c0855aad2f45fcaa2cf680799236f3e201 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 13:57:37 -0400 Subject: [PATCH 57/58] Add test for 0.5U devices --- netbox/dcim/tests/test_models.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index da54fc98d..98d57801d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -100,6 +100,7 @@ class RackTestCase(TestCase): device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5), ) DeviceType.objects.bulk_create(device_types) @@ -179,17 +180,37 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ site = Site.objects.first() rack = Rack.objects.first() - device = Device.objects.create( - name='TestPDU', + Device( + name='Device 1', device_role=DeviceRole.objects.first(), device_type=DeviceType.objects.first(), site=site, rack=rack - ) - self.assertTrue(device) + ).save() + + def test_mount_half_u_devices(self): + """ + Check that two 0.5U devices can be mounted in the same rack unit. + """ + rack = Rack.objects.first() + attrs = { + 'device_type': DeviceType.objects.get(u_height=0.5), + 'device_role': DeviceRole.objects.first(), + 'site': Site.objects.first(), + 'rack': rack, + 'face': DeviceFaceChoices.FACE_FRONT, + } + + Device(name='Device 1', position=1, **attrs).save() + Device(name='Device 2', position=1.5, **attrs).save() + + self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3) def test_change_rack_site(self): """ From 64080e808ee3b5c0c1b0217180518dc018c80f8e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 14:30:25 -0400 Subject: [PATCH 58/58] Refactor dcim.svg module --- netbox/dcim/svg/__init__.py | 2 + netbox/dcim/{svg.py => svg/cables.py} | 272 +------------------------ netbox/dcim/svg/racks.py | 279 ++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 270 deletions(-) create mode 100644 netbox/dcim/svg/__init__.py rename netbox/dcim/{svg.py => svg/cables.py} (54%) create mode 100644 netbox/dcim/svg/racks.py diff --git a/netbox/dcim/svg/__init__.py b/netbox/dcim/svg/__init__.py new file mode 100644 index 000000000..21e27d495 --- /dev/null +++ b/netbox/dcim/svg/__init__.py @@ -0,0 +1,2 @@ +from .cables import * +from .racks import * diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg/cables.py similarity index 54% rename from netbox/dcim/svg.py rename to netbox/dcim/svg/cables.py index 0986e94d3..eb0d2aca1 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg/cables.py @@ -1,285 +1,17 @@ -import decimal import svgwrite + +from django.conf import settings from svgwrite.container import Group, Hyperlink -from svgwrite.image import Image -from svgwrite.gradients import LinearGradient from svgwrite.shapes import Line, Rect from svgwrite.text import Text -from django.conf import settings -from django.urls import reverse -from django.utils.http import urlencode - -from netbox.config import get_config from utilities.utils import foreground_color -from .choices import DeviceFaceChoices -from .constants import RACK_ELEVATION_BORDER_WIDTH - __all__ = ( 'CableTraceSVG', - 'RackElevationSVG', ) -def get_device_name(device): - if device.virtual_chassis: - name = f'{device.virtual_chassis.name}:{device.vc_position}' - elif device.name: - name = device.name - else: - name = str(device.device_type) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - - return name - - -def get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - - -class RackElevationSVG: - """ - Use this class to render a rack elevation as an SVG image. - - :param rack: A NetBox Rack instance - :param user: User instance. If specified, only devices viewable by this user will be fully displayed. - :param include_images: If true, the SVG document will embed front/rear device face images, where available - :param base_url: Base URL for links within the SVG document. If none, links will be relative. - """ - def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, - base_url=None): - self.rack = rack - self.include_images = include_images - self.base_url = base_url.rstrip('/') if base_url is not None else '' - - # Set drawing dimensions - config = get_config() - self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT - - # Determine the subset of devices within this rack that are viewable by the user, if any - permitted_devices = self.rack.devices - if user is not None: - permitted_devices = permitted_devices.restrict(user, 'view') - self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - - @staticmethod - def _add_gradient(drawing, id_, color): - gradient = LinearGradient( - start=(0, 0), - end=(0, 25), - spreadMethod='repeat', - id_=id_, - gradientTransform='rotate(45, 0, 0)', - gradientUnits='userSpaceOnUse' - ) - gradient.add_stop_color(offset='0%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color=color) - gradient.add_stop_color(offset='100%', color=color) - - drawing.defs.add(gradient) - - def _setup_drawing(self): - width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 - height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - drawing = svgwrite.Drawing(size=(width, height)) - - # Add the stylesheet - with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: - drawing.defs.add(drawing.style(css_file.read())) - - # Add gradients - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') - - return drawing - - def _get_device_coords(self, position, height): - """ - Return the X, Y coordinates of the top left corner for a device in the specified rack unit. - """ - x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH - y = RACK_ELEVATION_BORDER_WIDTH - if self.rack.desc_units: - y += int((position - 1) * self.unit_height) - else: - y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) - - return x, y - - def _draw_device(self, device, coords, size, color=None, image=None): - name = get_device_name(device) - description = get_device_description(device) - text_coords = ( - coords[0] + size[0] / 2, - coords[1] + size[1] / 2 - ) - text_color = f'#{foreground_color(color)}' if color else '#000000' - - # Create hyperlink element - link = Hyperlink( - href='{}{}'.format( - self.base_url, - reverse('dcim:device', kwargs={'pk': device.pk}) - ), - target='_blank', - ) - link.set_desc(description) - if color: - link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) - else: - link.add(Rect(coords, size, class_='slot blocked')) - link.add(Text(name, insert=text_coords, fill=text_color)) - - # Embed device type image if provided - if self.include_images and image: - image = Image( - href='{}{}'.format(self.base_url, image.url), - insert=coords, - size=size, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(Text(name, insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) - - self.drawing.add(link) - - def draw_device_front(self, device, coords, size): - """ - Draw the front (mounted) face of a device. - """ - color = device.device_role.color - image = device.device_type.front_image - self._draw_device(device, coords, size, color=color, image=image) - - def draw_device_rear(self, device, coords, size): - """ - Draw the rear (opposite) face of a device. - """ - image = device.device_type.rear_image - self._draw_device(device, coords, size, image=image) - - def draw_border(self): - """ - Draw a border around the collection of rack units. - """ - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = Rect( - insert=(self.legend_width + border_offset, border_offset), - size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), - class_='rack' - ) - self.drawing.add(frame) - - def draw_legend(self): - """ - Draw the rack unit labels along the lefthand side of the elevation. - """ - for ru in range(0, self.rack.u_height): - start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH - position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) - unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - self.drawing.add( - Text(str(unit), position_coordinates, class_='unit') - ) - - def draw_background(self, face): - """ - Draw the rack unit placeholders which form the "background" of the rack elevation. - """ - x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width - url_string = '{}?{}&position={{}}'.format( - reverse('dcim:device_add'), - urlencode({ - 'site': self.rack.site.pk, - 'location': self.rack.location.pk if self.rack.location else '', - 'rack': self.rack.pk, - 'face': face, - }) - ) - - for ru in range(0, self.rack.u_height): - y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height - text_coords = ( - x_offset + self.unit_width / 2, - y_offset + self.unit_height / 2 - ) - - link = Hyperlink(href=url_string.format(ru), target='_blank') - link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) - link.add(Text('add device', insert=text_coords, class_='add-device')) - - self.drawing.add(link) - - def draw_face(self, face, opposite=False): - """ - Draw any occupied rack units for the specified rack face. - """ - for unit in self.rack.get_rack_units(face=face, expand_devices=False): - - # Loop through all units in the elevation - device = unit['device'] - height = unit.get('height', decimal.Decimal(1.0)) - - device_coords = self._get_device_coords(unit['id'], height) - device_size = ( - self.unit_width, - int(self.unit_height * height) - ) - - # Draw the device - if device and device.pk in self.permitted_device_ids: - if device.face == face and not opposite: - self.draw_device_front(device, device_coords, device_size) - else: - self.draw_device_rear(device, device_coords, device_size) - - elif device: - # Devices which the user does not have permission to view are rendered only as unavailable space - self.drawing.add(Rect(device_coords, device_size, class_='blocked')) - - def render(self, face): - """ - Return an SVG document representing a rack elevation. - """ - - # Initialize the drawing - self.drawing = self._setup_drawing() - - # Draw the empty rack & legend - self.draw_legend() - self.draw_background(face) - - # Draw the opposite rack face first, then the near face - if face == DeviceFaceChoices.FACE_REAR: - opposite_face = DeviceFaceChoices.FACE_FRONT - else: - opposite_face = DeviceFaceChoices.FACE_REAR - # self.draw_face(opposite_face, opposite=True) - self.draw_face(face) - - # Draw the rack border last - self.draw_border() - - return self.drawing - - OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py new file mode 100644 index 000000000..4d518adf1 --- /dev/null +++ b/netbox/dcim/svg/racks.py @@ -0,0 +1,279 @@ +import decimal +import svgwrite +from svgwrite.container import Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient +from svgwrite.shapes import Rect +from svgwrite.text import Text + +from django.conf import settings +from django.urls import reverse +from django.utils.http import urlencode + +from netbox.config import get_config +from utilities.utils import foreground_color +from dcim.choices import DeviceFaceChoices +from dcim.constants import RACK_ELEVATION_BORDER_WIDTH + + +__all__ = ( + 'RackElevationSVG', +) + + +def get_device_name(device): + if device.virtual_chassis: + name = f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + name = device.name + else: + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) + + +class RackElevationSVG: + """ + Use this class to render a rack elevation as an SVG image. + + :param rack: A NetBox Rack instance + :param user: User instance. If specified, only devices viewable by this user will be fully displayed. + :param include_images: If true, the SVG document will embed front/rear device face images, where available + :param base_url: Base URL for links within the SVG document. If none, links will be relative. + """ + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, + base_url=None): + self.rack = rack + self.include_images = include_images + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + + # Determine the subset of devices within this rack that are viewable by the user, if any + permitted_devices = self.rack.devices + if user is not None: + permitted_devices = permitted_devices.restrict(user, 'view') + self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = LinearGradient( + start=(0, 0), + end=(0, 25), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(45, 0, 0)', + gradientUnits='userSpaceOnUse' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color=color) + gradient.add_stop_color(offset='100%', color=color) + + drawing.defs.add(gradient) + + def _setup_drawing(self): + width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 + drawing = svgwrite.Drawing(size=(width, height)) + + # Add the stylesheet + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: + drawing.defs.add(drawing.style(css_file.read())) + + # Add gradients + RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + + return drawing + + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device(self, device, coords, size, color=None, image=None): + name = get_device_name(device) + description = get_device_description(device) + text_coords = ( + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 + ) + text_color = f'#{foreground_color(color)}' if color else '#000000' + + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', + ) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) + + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, + size=size, + class_='device-image' + ) + image.fit(scale='slice') + link.add(image) + link.add(Text(name, insert=text_coords, stroke='black', + stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) + + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' + ) + self.drawing.add(frame) + + def draw_legend(self): + """ + Draw the rack unit labels along the lefthand side of the elevation. + """ + for ru in range(0, self.rack.u_height): + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + self.drawing.add( + Text(str(unit), position_coordinates, class_='unit') + ) + + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 + ) + + link = Hyperlink(href=url_string.format(ru), target='_blank') + link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(Text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): + + # Loop through all units in the elevation + device = unit['device'] + height = unit.get('height', decimal.Decimal(1.0)) + + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) + + # Draw the device + if device and device.pk in self.permitted_device_ids: + if device.face == face and not opposite: + self.draw_device_front(device, device_coords, device_size) + else: + self.draw_device_rear(device, device_coords, device_size) + + elif device: + # Devices which the user does not have permission to view are rendered only as unavailable space + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # Draw the empty rack & legend + self.draw_legend() + self.draw_background(face) + + # Draw the opposite rack face first, then the near face + if face == DeviceFaceChoices.FACE_REAR: + opposite_face = DeviceFaceChoices.FACE_FRONT + else: + opposite_face = DeviceFaceChoices.FACE_REAR + # self.draw_face(opposite_face, opposite=True) + self.draw_face(face) + + # Draw the rack border last + self.draw_border() + + return self.drawing