From d76ede17d30548d311b89c33ae77377909f3b2dc Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Sat, 23 Sep 2023 21:33:47 +0200 Subject: [PATCH 01/33] Add data properties for device interface table Preparatory work for factoring row styling decisions out of Python code. --- netbox/dcim/tables/devices.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 34dbcbf30..fc8a3af73 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -69,6 +69,36 @@ def get_interface_state_attribute(record): return "disabled" +def get_interface_virtual_attribute(record): + """ + Get interface virtual state as string to attach to DOM element. + """ + if record.is_virtual: + return "true" + else: + return "false" + + +def get_interface_mark_connected_attribute(record): + """ + Get interface enabled state as string to attach to DOM element. + """ + if record.mark_connected: + return "true" + else: + return "false" + + +def get_interface_cable_status_attribute(record): + """ + Get interface enabled state as string to attach to DOM element. + """ + if record.cable: + return record.cable.status + else: + return "" + + # # Device roles # @@ -673,6 +703,9 @@ class DeviceInterfaceTable(InterfaceTable): 'class': get_interface_row_class, 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, + 'data-virtual': get_interface_virtual_attribute, + 'data-mark-connected': get_interface_mark_connected_attribute, + 'data-cable-status': get_interface_cable_status_attribute, 'data-type': lambda record: record.type, } From 41e1f24cf7c1c7431088eda7e9feebd323e9711b Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Sat, 23 Sep 2023 21:43:32 +0200 Subject: [PATCH 02/33] Add --nbx-color-* variables for theme colors Preparatory work for moving row styling to CSS --- netbox/project-static/dist/netbox-dark.css | Bin 375591 -> 376198 bytes netbox/project-static/dist/netbox-light.css | Bin 232798 -> 233371 bytes netbox/project-static/dist/netbox-print.css | Bin 727883 -> 728556 bytes netbox/project-static/styles/netbox.scss | 7 +++++++ 4 files changed, 7 insertions(+) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 2d7142bc6b6da7f32935de02c63becb00dc33c22..f9d1b619bcf5d7ce3693baa5ed80987964b0a350 100644 GIT binary patch delta 548 zcmZvYy-LJD6opw$thBI6Cx}*p192sP+W0DWGIz3r<0MS7tSK}W!56SdYbRR@!akMQ z+1NOf1#w)<;X7x}x##6Gc>D?;`bodlZ};At|GFIZ-o4$Uyp*LTl@>Rk+l#~F7$N0& zNreH$j)A^5B49M4%n?yJ$1(SBdDDoA!ghl5B;<+Xs^+?q)(2UN%&k-j zWwS6&BO0w;-FgqAky)}?wbb?w!#L7uGIg?Al(Jk{wt0S`5tTvz4B8+_OESju(=+GC qyaj8DabxR3Lpv^ZKqch%j4%ux?_wqz9Kt*AWVmAf&T6&vtNm}BAh&k_ delta 25 hcmZqsEw=odSVIeA3sVbo3(FSPjVrectYIw^1OSbd3HJa1 diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index ffdd83285e235e4f723f8f46937a2c0ab5033570..66829e602dd097a0d7a9fff33bb7b047ba68c82c 100644 GIT binary patch delta 589 zcmZvZO-{ow5JpwHMDz?utRp#f>@?*dtex>VwQB6hNmVKqp;B+a4i>;2kl1k)&VsG* zXVLZZ%s1cj$HVOLX?Ay*98O-RFZ*HXo$GJ2%+@yJYUlbq5;+S;Rx;Q#5)hhFzrle6L1{SJ+B+wlH~hQ=e>fR32$QJ6sH1ga`^2RjjG phy?NDe3a055&u}onzA~TP#XcU4I!zw?MnBtzf$V$c|UzW{RBeQzuEu* delta 26 icmbO|pYPr*zJ?aY7N#xC3mc~&sAp!|{-l|CJ2wEAwhHwC diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index b492e4d1dbd0bfe6538e68d291f6d78972bb52ea..5368b2956d88f1f8c109c0dd4b7fccd8706310dd 100644 GIT binary patch delta 576 zcmZY6y-ve06a`=vXrWy}VnyoONKW$)&%)r?*Qtf&ASYGG6b4Uhh@k^g-+%?7GB7do zDhzC0v4No`qxH$x=N^A-W}k1fTRqTwdS4&tp&sc&eWb@Pll6A>csITrl2VlF{_CWZ z`HlB;@G2msq*%4*XVaJ@Q9>^~Z-*!*wa zZxIjggX5hhuDP`1X&eNpyQczbp&zzX*lc2q(FA<=QVT+qjkSIQg#wU<{H*JhS_2k_ z=7?mO+Z$=Iy@V*i|LQIjw*QA|9_8--xxmD3g^=4=v*5K2zIskar?2bnF7M2#)7Pc1l7LFFqEnM@yZ@2%&wN@Db|HTdO diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 94fddc32c..f7d3d7069 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -835,6 +835,13 @@ table tbody { } } +// Expose theme colors as variables. (Useful for dynamic styling of choices etc.) +:root { + @each $color, $value in $theme-colors { + --nbx-color-#{$color}: #{$value}; + } +} + // Style objects with statuses/roles within a table. As of implementation, used for IP addresses // assigned to interfaces. table .table-badge-group { From d44f67aea5c37d43075ef81a94ccaeff964c02a6 Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Sat, 23 Sep 2023 23:01:08 +0200 Subject: [PATCH 03/33] Add 15% alpha variants of --nbx-color Preparatory work for factoring row styling out of Python --- netbox/project-static/dist/netbox-dark.css | Bin 376198 -> 377206 bytes netbox/project-static/dist/netbox-light.css | Bin 233371 -> 234355 bytes netbox/project-static/dist/netbox-print.css | Bin 728556 -> 729540 bytes netbox/project-static/styles/netbox.scss | 2 ++ 4 files changed, 2 insertions(+) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index f9d1b619bcf5d7ce3693baa5ed80987964b0a350..3032fe467ea47d59c64340686dd42ea368d0ac99 100644 GIT binary patch delta 850 zcmZvaKTE?v7{>9>iG!dn>L8L@qgER6u1Q;UaC33>3tZA`4928fTWbeFqzFzO9J0AM zHE|IHCj~!%lV8Bq(M1p!--{_+Xd&DXe)qi3`#g8`JNJD#f6*D~jCRI4ZAK1%B@tW zXEQF71u%`{LkH#9z8RwRtn1iE4H+chjSx^H1@IAr=iqV@c03l`|BkmIQkG4($)u~p zB64w=L=-eALQlh@poVsRTlU4UZA82l8G)f_K2lav!%#RZ{S z#tAnTq16m$6=^OR62f8xars>2C6l6KToS?Je%ob1wwdettrR&}!C?f!+MF>@0x=$} z-;ZFI2BWUb5!fKEA8+@Yx)z+(rfI#wXL2Vvn^XDWk|BjYM@T&|U24f8g53WnV&2s> n&pgvC-}SlHI&vCpN6A*J)xrP5`c1dH5WMb^6{4^QK*t_n|u z8aaY_2bKz2qU^D%#W=^(bgR}b^D6n{7@TG4Cvm$#^Q##s<-f*Y+r5GB%U;hA;2!q6 zSeo^s|IZk9N@3hd42phoMx|aWu(cFE*+Ze?Fb)iodnCAzX7&oOkwRs$E#g2b;_~0K zunJ`KD21{eg~3VR?02!1yA3M)g~DkHI~zF7SA+9#X0KVb8WY=>2$fpVKpGH3EymC> z_a^DwpquEo=14|SGMobc&-zOSI1W5Hqm oJtY3wapKmQ<0ny~oZ(5>mj{;J>2%(I*B-AAVeR21gP%)(0kTillK=n! delta 125 zcmeyojBoaQzJ?aY7N#xC7aFHCHZdzq4{c(WnZB@zS!(*rCT7{`X3aouV>7e#^fS#s zF`*V95!C_|Th;=U{oKMVIo+z2S#oj^qtNt&t;}lE#oB;sxs TJ2Ut6_zq^Z?YG;RXK(`mA51Je diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 5368b2956d88f1f8c109c0dd4b7fccd8706310dd..3c58d79c006a25c5beea279a242142bc7c8fcde4 100644 GIT binary patch delta 807 zcmZXSzfK!L5XJ>|g1u=-;TE5LC>V5yylbETfKZeoU3dYywS7KLIqs~L!Er~iU67EV zxRw{7L!zJri6_Vd&__zUN5#xy!xE&^>a_apH{X0Se{Y<-Tjy9V$VHixdHF$pluNQ8 zi&tbtt&T)^z{S+%)Z4N|(Bn0aIu>ONj!wg|HmSFsepbXy%Ze28$fe(Z7b|I75j)*c zWa;_t_@r3MA-k){S|yB!i6Pl(Iu@gNH0v|TK#>i5!bKd$fk86QgTh@X+@66_k@f0; z$AK?Qj)9N|SgSkZE)=QRQ5fuxO{@V3Yqao~gEwHXJmBJ|!FkZG*8w%~s5}0HUG$K1UiVa=^&P8S>D8B8waqpMgPohM)ll^e)Z&~(mIDCJvJ)SS;OF4L#R;-26_18yo GX+Htfc;iz5 delta 148 zcmX?dSm(`dorV_17N!>F7M2#)7Pc1l7LFFqEnIRxrsw_O;+!7wgG+q+;vZZx)8GH# zlA3P*lS_7b%TFNp{7)|F=~BOdVhJE(#V?@PmtR0x$KPC%(>p-K+224h$v;3M Date: Sat, 23 Sep 2023 23:07:16 +0200 Subject: [PATCH 04/33] Move DeviceInterfaceTable coloring logic into CSS Preparatory work for simplifying toggle button code for cable status. --- netbox/dcim/tables/devices.py | 11 ++--------- netbox/templates/dcim/device/interfaces.html | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index fc8a3af73..063e05215 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -6,6 +6,7 @@ from dcim import models from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from .template_code import * +from dcim.choices import LinkStatusChoices __all__ = ( 'BaseInterfaceTable', @@ -51,14 +52,6 @@ def get_cabletermination_row_class(record): return '' -def get_interface_row_class(record): - if not record.enabled: - return 'danger' - elif record.is_virtual: - return 'primary' - return get_cabletermination_row_class(record) - - def get_interface_state_attribute(record): """ Get interface enabled state as string to attach to DOM element. @@ -700,7 +693,6 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', ) row_attrs = { - 'class': get_interface_row_class, 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, 'data-virtual': get_interface_virtual_attribute, @@ -708,6 +700,7 @@ class DeviceInterfaceTable(InterfaceTable): 'data-cable-status': get_interface_cable_status_attribute, 'data-type': lambda record: record.type, } + cable_status_styles = [(slug, color) for slug, _, color in LinkStatusChoices.CHOICES] class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 8b3fe3097..54682439c 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -30,3 +30,23 @@ {% endif %} {% endblock bulk_extra_controls %} + +{% block head %} + {{ block.super }} + +{% endblock %} From 83e2c45e74e2a28df8c9b3b42a89fd61788f631d Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Sat, 23 Sep 2023 23:45:08 +0200 Subject: [PATCH 05/33] Simplify mark connected/installed implementation Fixes: #13712 and #13806. --- netbox/project-static/dist/netbox.js | Bin 530368 -> 529923 bytes netbox/project-static/dist/netbox.js.map | Bin 450659 -> 450364 bytes .../src/buttons/connectionToggle.ts | 36 +++++------------- netbox/templates/dcim/device/interfaces.html | 9 ++++- .../dcim/inc/cable_toggle_buttons.html | 15 +++----- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 84bfecae34920e732ecf52670208390ce6712040..d457ae229f71cfb090301054275a1d36b6ddfb16 100644 GIT binary patch delta 15474 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 delta 15773 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 Iygcgv0oR~A9smFU diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 7f2400ed2610973ed909703e534ec9b73c19c8ae..51042c43167bdeace2fa7ed21428254800856a7e 100644 GIT binary patch delta 181 zcmaESKzh$U>4p}@7N!>F7M3ln%-3B7Smnf> z&A~FhAVq;XiH?qmI+2d9S<@rmu^KWv7dTI!{EpRJ(%n(V)6vlrBA@T%=ve5a(yTx>4GnRCy4Gw&b6pU>e_xlwMGPT6}^PJJ@cbOc;8G1wiOI-5nwxLRVk4IvC6}h3si>{C2id->YA7Q-uZq diff --git a/netbox/project-static/src/buttons/connectionToggle.ts b/netbox/project-static/src/buttons/connectionToggle.ts index 74b32dc3a..ed119f738 100644 --- a/netbox/project-static/src/buttons/connectionToggle.ts +++ b/netbox/project-static/src/buttons/connectionToggle.ts @@ -7,10 +7,10 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util'; * * @param element Connection Toggle Button Element */ -function toggleConnection(element: HTMLButtonElement): void { +function setConnectionStatus(element: HTMLButtonElement, status: string): void { + // Get the button's row to change its data-cable-status attribute + const row = element.parentElement?.parentElement as HTMLTableRowElement; const url = element.getAttribute('data-url'); - const connected = element.classList.contains('connected'); - const status = connected ? 'planned' : 'connected'; if (isTruthy(url)) { apiPatch(url, { status }).then(res => { @@ -19,34 +19,18 @@ function toggleConnection(element: HTMLButtonElement): void { createToast('danger', 'Error', res.error).show(); return; } else { - // Get the button's row to change its styles. - const row = element.parentElement?.parentElement as HTMLTableRowElement; - // Get the button's icon to change its CSS class. - const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement; - if (connected) { - row.classList.remove('success'); - row.classList.add('info'); - element.classList.remove('connected', 'btn-warning'); - element.classList.add('btn-info'); - element.title = 'Mark Installed'; - icon.classList.remove('mdi-lan-disconnect'); - icon.classList.add('mdi-lan-connect'); - } else { - row.classList.remove('info'); - row.classList.add('success'); - element.classList.remove('btn-success'); - element.classList.add('connected', 'btn-warning'); - element.title = 'Mark Installed'; - icon.classList.remove('mdi-lan-connect'); - icon.classList.add('mdi-lan-disconnect'); - } + // Update cable status in DOM + row.setAttribute('data-cable-status', status); } }); } } export function initConnectionToggle(): void { - for (const element of getElements('button.cable-toggle')) { - element.addEventListener('click', () => toggleConnection(element)); + for (const element of getElements('button.mark-planned')) { + element.addEventListener('click', () => setConnectionStatus(element, 'planned')); + } + for (const element of getElements('button.mark-installed')) { + element.addEventListener('click', () => setConnectionStatus(element, 'connected')); } } diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 54682439c..c4aee9784 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -48,5 +48,12 @@ tr[data-enabled=disabled] { background-color: var(--nbx-color-danger-a15); } - + tr[data-cable-status=connected] button.mark-installed { + display: none; + } + tr:not([data-cable-status=connected]) button.mark-planned { + display: none; + } + + {% endblock %} diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html index 4d8d995c4..1c5427337 100644 --- a/netbox/templates/dcim/inc/cable_toggle_buttons.html +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -1,12 +1,9 @@ {% load i18n %} {% if perms.dcim.change_cable %} - {% if cable.status == 'connected' %} - - {% else %} - - {% endif %} + + {% endif %} From c728d3c2e83cbfebfacf564ea8d3dae79dedd5ac Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Sun, 24 Sep 2023 00:08:39 +0200 Subject: [PATCH 06/33] Fix formatting --- netbox/templates/dcim/device/interfaces.html | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index c4aee9784..8669789c7 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -35,9 +35,9 @@ {{ block.super }} + {% endblock %} From 6af3aad36262713c22647f7f9d2565e038ae0b02 Mon Sep 17 00:00:00 2001 From: Moritz Geist Date: Tue, 9 Jan 2024 13:51:09 +0100 Subject: [PATCH 07/33] Fixes #14722, Fixes #13922: Update the CableRender This commit updates the cable rendering logic to fix both issue #14722 and #13922. Before, objects, terminations and cables where drawn in the svg without context of each other. Now the following changes are applied: - Hosts and Terminations are where possible sorted alphabetically - Terminations and Cables are visually connected, and if necessary not in a vertical line - Terminations and Hosts are visually aligning - Cable Tooltips contain more information --- netbox/dcim/svg/cables.py | 363 ++++++++++++++++++-------------------- 1 file changed, 174 insertions(+), 189 deletions(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index d7365161e..76d6dc68a 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -8,17 +8,16 @@ from django.conf import settings from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from utilities.utils import foreground_color - __all__ = ( 'CableTraceSVG', ) - OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 FANOUT_HEIGHT = 35 FANOUT_LEG_HEIGHT = 15 +CABLE_HEIGHT = 4 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT class Node(Hyperlink): @@ -84,31 +83,38 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], description=[], **extra): - super().__init__(class_='connector', **extra) + def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra): + super().__init__(class_="connector", **extra) self.start = start self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - self.end = (start[0], start[1] + self.height) + # Allow to specify end-position or auto-calculate + self.end = end if end else (start[0], start[1] + self.height) self.color = color or '000000' - # Draw a "shadow" line to give the cable a border - cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') - self.add(cable_shadow) + if wireless: + # Draw the cable + cable = Line(start=self.start, end=self.end, class_="wireless-link") + self.add(cable) + else: + # Draw a "shadow" line to give the cable a border + cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') + self.add(cable_shadow) - # Draw the cable - cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') - self.add(cable) + # Draw the cable + cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') + self.add(cable) # Add link link = Hyperlink(href=url, target='_parent') # Add text label(s) - cursor = start[1] - cursor += PADDING * 2 + cursor = start[1] + text_offset + cursor += PADDING * 2 + LINE_HEIGHT * 2 + x_coord = (start[0] + end[0]) / 2 + PADDING for i, label in enumerate(labels): cursor += LINE_HEIGHT - text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) + text_coords = (x_coord, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) if len(description) > 0: @@ -190,8 +196,9 @@ class CableTraceSVG: def draw_parent_objects(self, obj_list): """ - Draw a set of parent objects. + Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes """ + objects = [] width = self.width / len(obj_list) for i, obj in enumerate(obj_list): node = Node( @@ -199,23 +206,26 @@ class CableTraceSVG: width=width, url=f'{self.base_url}{obj.get_absolute_url()}', color=self._get_color(obj), - labels=self._get_labels(obj) + labels=self._get_labels(obj), + object=obj ) + objects.append(node) self.parent_objects.append(node) if i + 1 == len(obj_list): self.cursor += node.box['height'] + return objects - def draw_terminations(self, terminations): + def draw_object_terminations(self, terminations, offset_x, width): """ - Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable. + Draw all terminations belonging to an object with specified offset and width + Return all created nodes and their maximum height """ - nodes = [] nodes_height = 0 - width = self.width / len(terminations) - - for i, term in enumerate(terminations): + nodes = [] + # Sort them by name to make renders more readable + for i, term in enumerate(sorted(terminations, key=lambda x: x.name)): node = Node( - position=(i * width, self.cursor), + position=(offset_x + i * width, self.cursor), width=width, url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), @@ -225,133 +235,93 @@ class CableTraceSVG: ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) + return nodes, nodes_height + + def draw_terminations(self, terminations, parent_object_nodes): + """ + Draw a row of terminating objects (e.g. interfaces) and return all created nodes + Attach them to previously created parent objects + """ + nodes = [] + nodes_height = 0 + + # Draw terminations for each parent object + for parent in parent_object_nodes: + parent_terms = [term for term in terminations if term.parent_object == parent.object] + + if len(parent_terms) == 0: + self.logger.warn(f"No Parent Terminations? {parent.object.name}") + continue + + # Width and offset(position) for each termination box + width = parent.box['width'] / len(parent_terms) + offset_x = parent.box['x'] + + result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width) + nodes.extend(result) self.cursor += nodes_height self.terminations.extend(nodes) return nodes - def draw_fanin(self, node, connector): - points = ( - node.bottom_center, - (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT), - connector.start, - ) - self.connectors.extend(( - Polyline(points=points, class_='cable-shadow'), - Polyline(points=points, style=f'stroke: #{connector.color}'), - )) - - def draw_fanout(self, node, connector): - points = ( - connector.end, - (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT), - node.top_center, - ) - self.connectors.extend(( - Polyline(points=points, class_='cable-shadow'), - Polyline(points=points, style=f'stroke: #{connector.color}'), - )) - - def draw_cable(self, cable, terminations, cable_count=0): + def draw_far_objects(self, obj_list, terminations): """ - Draw a single cable. Terminations and cable count are passed for determining position and padding - - :param cable: The cable to draw - :param terminations: List of terminations to build positioning data off of - :param cable_count: Count of all cables on this layer for determining whether to collapse description into a - tooltip. + Draw the far-end objects and its terminations and return all created nodes """ + # Make sure elements are sorted by name for readability + objects = sorted(obj_list, key=lambda x: x.name) + width = self.width / len(objects) - # If the cable count is higher than 2, collapse the description into a tooltip - if cable_count > 2: - # Use the cable __str__ function to denote the cable - labels = [f'{cable}'] + # Max-height of created terminations + terms_height = 0 + term_nodes = [] - # Include the label and the status description in the tooltip - description = [ - f'Cable {cable}', - cable.get_status_display() - ] + # Draw the terminations by per object first + for i, obj in enumerate(objects): + obj_terms = [term for term in terminations if term.parent_object == obj] + obj_pos = i * width + result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms)) - if cable.type: - # Include the cable type in the tooltip - description.append(cable.get_type_display()) - if cable.length is not None and cable.length_unit: - # Include the cable length in the tooltip - description.append(f'{cable.length} {cable.get_length_unit_display()}') - else: - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - description = [] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length is not None and cable.length_unit: - # Include the cable length in the tooltip - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + terms_height = max(terms_height, result_nodes_height) + term_nodes.extend(result) - # If there is only one termination, center on that termination - # Otherwise average the center across the terminations - if len(terminations) == 1: - center = terminations[0].bottom_center[0] - else: - # Get a list of termination centers - termination_centers = [term.bottom_center[0] for term in terminations] - # Average the centers - center = sum(termination_centers) / len(termination_centers) + # Update cursor and draw the objects + self.cursor += terms_height + self.terminations.extend(term_nodes) + object_nodes = self.draw_parent_objects(objects) - # Create the connector - connector = Connector( - start=(center, self.cursor), - color=cable.color or '000000', - url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels, - description=description - ) + return object_nodes, term_nodes - # Set the cursor position - self.cursor += connector.height - - return connector - - def draw_wirelesslink(self, wirelesslink): + def draw_fanin(self, target, terminations, color): """ - Draw a line with labels representing a WirelessLink. + Draw the fan-in-lines from each of the terminations to the targetpoint """ - group = Group(class_='connector') + for term in terminations: + points = ( + term.bottom_center, + (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT), + target, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{color}'), + )) - labels = [ - f'Wireless link {wirelesslink}', - wirelesslink.get_status_display() - ] - if wirelesslink.ssid: - labels.append(wirelesslink.ssid) - - # Draw the wireless link - start = (OFFSET + self.center, self.cursor) - height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - end = (start[0], start[1] + height) - line = Line(start=start, end=end, class_='wireless-link') - group.add(line) - - self.cursor += PADDING * 2 - - # Add link - link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent') - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) - text = Text(label, insert=text_coords, class_='bold' if not i else []) - link.add(text) - - group.add(link) - self.cursor += PADDING * 2 - - return group + def draw_fanout(self, start, terminations, color): + """ + Draw the fan-out-lines from the startpoint to each of the terminations + """ + for term in terminations: + points = ( + term.top_center, + (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT), + start, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{color}'), + )) def draw_attachment(self): """ @@ -378,86 +348,101 @@ class CableTraceSVG: traced_path = self.origin.trace() + parent_object_nodes = [] # Iterate through each (terms, cable, terms) segment in the path for i, segment in enumerate(traced_path): near_ends, links, far_ends = segment - # Near end parent + # This is segment number one. if i == 0: # If this is the first segment, draw the originating termination's parent object - self.draw_parent_objects(set(end.parent_object for end in near_ends)) + parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends)) + # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!) - # Near end termination(s) - terminations = self.draw_terminations(near_ends) + near_terminations = self.draw_terminations(near_ends, parent_object_nodes) + self.cursor += CABLE_HEIGHT # Connector (a Cable or WirelessLink) if links: - link_cables = {} - fanin = False - fanout = False - # Determine if we have fanins or fanouts - if len(near_ends) > len(set(links)): - self.cursor += FANOUT_HEIGHT - fanin = True - if len(far_ends) > len(set(links)): - fanout = True - cursor = self.cursor - for link in links: - # Cable - if type(link) is Cable and not link_cables.get(link.pk): - # Reset cursor - self.cursor = cursor - # Generate a list of terminations connected to this cable - near_end_link_terminations = [term for term in terminations if term.object.cable == link] - # Draw the cable - cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) - # Add cable to the list of cables - link_cables.update({link.pk: cable}) - # Add cable to drawing - self.connectors.append(cable) + parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends) + for cable in links: + # Fill in labels and description with all available data + description = [ + f"Link {cable}", + cable.get_status_display() + ] + near = [] + far = [] + color = '000000' + if cable.description: + description.append(f"{cable.description}") + if cable is Cable: + labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()] + if cable.label: + description.append(f"NetBox ID: {cable.id}") + if cable.type: + description.append(f"Type: {cable.get_type_display()}") + if cable.length: + description.append(f"Length: {cable.length} {cable.length_unit}") + color = cable.color or '000000' - # Draw fan-ins - if len(near_ends) > 1 and fanin: - for term in terminations: - if term.object.cable == link: - self.draw_fanin(term, cable) + # Collect all connected nodes to this cable + near = [term for term in near_terminations if term.object in cable.a_terminations] + far = [term for term in far_terminations if term.object in cable.b_terminations] + elif cable is WirelessLink: + labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] + if cable.ssid: + description.append(f"SSID: {cable.ssid}") + if cable.auth_type: + description.append(f"AuthType: {cable.auth_type}") + if cable.auth_cipher: + description.append(f"AuthCipher: {cable.auth_cipher}") + if cable.auth_psk: + description.append(f"PSK is set") + near = [term for term in near_terminations if term.object == cable.interface_a] + far = [term for term in far_terminations if term.object == cable.interface_b] + if cable.tenant: + description.append(f"Tenant: {cable.tenant}") - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # Select most-probable start and end position + start = near[0].bottom_center + end = far[0].top_center + text_offset = 0 - # Far end termination(s) - if len(far_ends) > 1: - if fanout: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): - self.draw_fanout(term, link_cables.get(term.object.cable.pk)) - else: - self.draw_terminations(far_ends) - elif far_ends: - self.draw_terminations(far_ends) - else: - # Link is not connected to anything - break + if len(near) > 1: + # Handle Fan-In - change start position to be directly below start + start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) + self.draw_fanin(start, near, color) + text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT + elif len(far) > 1: + # Handle Fan-Out - change end position to be directly above end + end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT) + self.draw_fanout(end, far, color) + text_offset -= FANOUT_HEIGHT - # Far end parent - parent_objects = set(end.parent_object for end in far_ends) - self.draw_parent_objects(parent_objects) + # Create the connector + connector = Connector( + start=start, + end=end, + color=color, + wireless=cable is WirelessLink, + url=f'{self.base_url}{cable.get_absolute_url()}', + text_offset=text_offset, + labels=labels, + description=description + ) + self.connectors.append(connector) # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with # a CircuitTermination) elif far_ends: - # Attachment attachment = self.draw_attachment() self.connectors.append(attachment) # Object - self.draw_parent_objects(far_ends) + parent_object_nodes = self.draw_parent_objects(far_ends) # Determine drawing size self.drawing = svgwrite.Drawing( From ced44832f75913f93a7768e9ee001f1e313bb545 Mon Sep 17 00:00:00 2001 From: Moritz Geist Date: Tue, 9 Jan 2024 14:22:36 +0100 Subject: [PATCH 08/33] Remove dangling logging message used during development --- netbox/dcim/svg/cables.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 76d6dc68a..7bbb3c2b0 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -249,10 +249,6 @@ class CableTraceSVG: for parent in parent_object_nodes: parent_terms = [term for term in terminations if term.parent_object == parent.object] - if len(parent_terms) == 0: - self.logger.warn(f"No Parent Terminations? {parent.object.name}") - continue - # Width and offset(position) for each termination box width = parent.box['width'] / len(parent_terms) offset_x = parent.box['x'] From 2c93dd03e12acd0c9754d6e051166dcdd59d6329 Mon Sep 17 00:00:00 2001 From: Moritz Geist Date: Wed, 10 Jan 2024 14:29:46 +0100 Subject: [PATCH 09/33] account for swapped terminations in cable object also remove out-of-scope changes to tooltips --- netbox/dcim/svg/cables.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 7bbb3c2b0..596f0c6bf 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -373,33 +373,31 @@ class CableTraceSVG: color = '000000' if cable.description: description.append(f"{cable.description}") - if cable is Cable: + if isinstance(cable, Cable): labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()] - if cable.label: - description.append(f"NetBox ID: {cable.id}") if cable.type: - description.append(f"Type: {cable.get_type_display()}") - if cable.length: - description.append(f"Length: {cable.length} {cable.length_unit}") + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + description.append(f"{cable.length} {cable.get_length_unit_display()}") color = cable.color or '000000' # Collect all connected nodes to this cable near = [term for term in near_terminations if term.object in cable.a_terminations] far = [term for term in far_terminations if term.object in cable.b_terminations] - elif cable is WirelessLink: + if not (near and far): + # a and b terminations may be swapped + near = [term for term in near_terminations if term.object in cable.b_terminations] + far = [term for term in far_terminations if term.object in cable.a_terminations] + elif isinstance(cable, WirelessLink): labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] if cable.ssid: - description.append(f"SSID: {cable.ssid}") - if cable.auth_type: - description.append(f"AuthType: {cable.auth_type}") - if cable.auth_cipher: - description.append(f"AuthCipher: {cable.auth_cipher}") - if cable.auth_psk: - description.append(f"PSK is set") + description.append(f"{cable.ssid}") near = [term for term in near_terminations if term.object == cable.interface_a] far = [term for term in far_terminations if term.object == cable.interface_b] - if cable.tenant: - description.append(f"Tenant: {cable.tenant}") + if not (near and far): + # a and b terminations may be swapped + near = [term for term in near_terminations if term.object == cable.interface_b] + far = [term for term in far_terminations if term.object == cable.interface_a] # Select most-probable start and end position start = near[0].bottom_center @@ -422,7 +420,7 @@ class CableTraceSVG: start=start, end=end, color=color, - wireless=cable is WirelessLink, + wireless=isinstance(cable, WirelessLink), url=f'{self.base_url}{cable.get_absolute_url()}', text_offset=text_offset, labels=labels, From da7f67c35951d54d7d6c318fe134574538aa7ec5 Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Tue, 23 Jan 2024 20:49:10 +0100 Subject: [PATCH 10/33] Refactor noisy getter methods into neat lambdas --- netbox/dcim/tables/devices.py | 48 +++-------------------------------- 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 063e05215..fcacd886a 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -52,46 +52,6 @@ def get_cabletermination_row_class(record): return '' -def get_interface_state_attribute(record): - """ - Get interface enabled state as string to attach to DOM element. - """ - if record.enabled: - return "enabled" - else: - return "disabled" - - -def get_interface_virtual_attribute(record): - """ - Get interface virtual state as string to attach to DOM element. - """ - if record.is_virtual: - return "true" - else: - return "false" - - -def get_interface_mark_connected_attribute(record): - """ - Get interface enabled state as string to attach to DOM element. - """ - if record.mark_connected: - return "true" - else: - return "false" - - -def get_interface_cable_status_attribute(record): - """ - Get interface enabled state as string to attach to DOM element. - """ - if record.cable: - return record.cable.status - else: - return "" - - # # Device roles # @@ -694,10 +654,10 @@ class DeviceInterfaceTable(InterfaceTable): ) row_attrs = { 'data-name': lambda record: record.name, - 'data-enabled': get_interface_state_attribute, - 'data-virtual': get_interface_virtual_attribute, - 'data-mark-connected': get_interface_mark_connected_attribute, - 'data-cable-status': get_interface_cable_status_attribute, + 'data-enabled': lambda record: "enabled" if record.enabled else "disabled", + 'data-virtual': lambda record: "true" if record.is_virtual else "false", + 'data-mark-connected': lambda record: "true" if record.mark_connected else "false", + 'data-cable-status': lambda record: record.cable.status if record.cable else "", 'data-type': lambda record: record.type, } cable_status_styles = [(slug, color) for slug, _, color in LinkStatusChoices.CHOICES] From bf362f4679f9d9c30519ea1ce80076c59a79e8c2 Mon Sep 17 00:00:00 2001 From: Per von Zweigbergk Date: Tue, 23 Jan 2024 20:58:10 +0100 Subject: [PATCH 11/33] Hardcode cable status colours --- netbox/dcim/tables/devices.py | 2 -- netbox/templates/dcim/device/interfaces.html | 14 +++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index fcacd886a..d15cfe64d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -6,7 +6,6 @@ from dcim import models from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from .template_code import * -from dcim.choices import LinkStatusChoices __all__ = ( 'BaseInterfaceTable', @@ -660,7 +659,6 @@ class DeviceInterfaceTable(InterfaceTable): 'data-cable-status': lambda record: record.cable.status if record.cable else "", 'data-type': lambda record: record.type, } - cable_status_styles = [(slug, color) for slug, _, color in LinkStatusChoices.CHOICES] class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 8669789c7..9860d74ef 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -34,11 +34,15 @@ {% block head %} {{ block.super }} -{% endblock %} From 0b0dab42eb981bfb0d17f9c9164109cf016dd9f0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Apr 2024 12:23:31 -0400 Subject: [PATCH 13/33] PRVB --- docs/release-notes/version-3.7.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 062dc3fe7..64fdc7dfe 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,5 +1,9 @@ # NetBox v3.7 +## v3.7.7 (FUTURE) + +--- + ## v3.7.6 (2024-04-22) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 764aa049a..94fb9f891 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig # Environment setup # -VERSION = '3.7.6' +VERSION = '3.7.7-dev' # Hostname HOSTNAME = platform.node() From d606cf1b3c193c5070b36dcbc122bb511bf33f1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Apr 2024 15:50:38 -0400 Subject: [PATCH 14/33] Update source translations --- netbox/translations/en/LC_MESSAGES/django.po | 399 +++++++++++-------- 1 file changed, 228 insertions(+), 171 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index dfb5a7a59..2a5a12ba7 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-04 19:11+0000\n" +"POT-Creation-Date: 2024-04-22 19:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -99,7 +99,7 @@ msgstr "" #: dcim/filtersets.py:903 dcim/filtersets.py:1207 dcim/filtersets.py:1702 #: dcim/filtersets.py:1945 dcim/filtersets.py:2003 ipam/filtersets.py:305 #: ipam/filtersets.py:896 virtualization/filtersets.py:45 -#: virtualization/filtersets.py:173 vpn/filtersets.py:330 +#: virtualization/filtersets.py:173 vpn/filtersets.py:341 msgid "Region (ID)" msgstr "" @@ -109,7 +109,7 @@ msgstr "" #: dcim/filtersets.py:1952 dcim/filtersets.py:2010 extras/filtersets.py:414 #: ipam/filtersets.py:312 ipam/filtersets.py:903 #: virtualization/filtersets.py:52 virtualization/filtersets.py:180 -#: vpn/filtersets.py:325 +#: vpn/filtersets.py:336 msgid "Region (slug)" msgstr "" @@ -182,7 +182,7 @@ msgstr "" #: dcim/filtersets.py:363 extras/filtersets.py:436 ipam/filtersets.py:215 #: ipam/filtersets.py:335 ipam/filtersets.py:926 #: virtualization/filtersets.py:75 virtualization/filtersets.py:203 -#: vpn/filtersets.py:335 +#: vpn/filtersets.py:346 msgid "Site (slug)" msgstr "" @@ -227,7 +227,7 @@ msgstr "" #: dcim/filtersets.py:1232 dcim/filtersets.py:1727 dcim/filtersets.py:1969 #: dcim/filtersets.py:2028 ipam/filtersets.py:209 ipam/filtersets.py:329 #: ipam/filtersets.py:920 virtualization/filtersets.py:69 -#: virtualization/filtersets.py:197 vpn/filtersets.py:340 +#: virtualization/filtersets.py:197 vpn/filtersets.py:351 msgid "Site (ID)" msgstr "" @@ -239,11 +239,11 @@ msgstr "" #: extras/filtersets.py:403 extras/filtersets.py:562 extras/filtersets.py:604 #: extras/filtersets.py:645 ipam/forms/model_forms.py:416 #: netbox/filtersets.py:275 netbox/forms/__init__.py:23 -#: netbox/forms/base.py:163 templates/htmx/object_selector.html:28 +#: netbox/forms/base.py:158 templates/htmx/object_selector.html:28 #: templates/inc/filter_list.html:53 templates/ipam/ipaddress_assign.html:32 #: templates/search.html:7 templates/search.html:26 tenancy/filtersets.py:87 #: users/filtersets.py:21 users/filtersets.py:37 users/filtersets.py:69 -#: users/filtersets.py:117 utilities/forms/forms.py:99 +#: users/filtersets.py:117 utilities/forms/forms.py:105 msgid "Search" msgstr "" @@ -579,7 +579,7 @@ msgstr "" #: circuits/forms/bulk_edit.py:169 circuits/forms/model_forms.py:112 #: dcim/forms/model_forms.py:141 dcim/forms/model_forms.py:183 #: dcim/forms/model_forms.py:260 dcim/forms/model_forms.py:679 -#: dcim/forms/model_forms.py:1485 ipam/forms/model_forms.py:61 +#: dcim/forms/model_forms.py:1574 ipam/forms/model_forms.py:61 #: ipam/forms/model_forms.py:114 ipam/forms/model_forms.py:135 #: ipam/forms/model_forms.py:159 ipam/forms/model_forms.py:231 #: ipam/forms/model_forms.py:257 netbox/navigation/menu.py:38 @@ -620,18 +620,19 @@ msgstr "" #: ipam/forms/bulk_import.py:258 ipam/forms/bulk_import.py:294 #: ipam/forms/bulk_import.py:460 virtualization/forms/bulk_import.py:56 #: virtualization/forms/bulk_import.py:82 vpn/forms/bulk_import.py:39 +#: wireless/forms/bulk_import.py:45 msgid "Operational status" msgstr "" #: circuits/forms/bulk_import.py:104 dcim/forms/bulk_import.py:110 #: dcim/forms/bulk_import.py:155 dcim/forms/bulk_import.py:286 #: dcim/forms/bulk_import.py:428 dcim/forms/bulk_import.py:1171 -#: dcim/forms/bulk_import.py:1319 ipam/forms/bulk_import.py:41 -#: ipam/forms/bulk_import.py:70 ipam/forms/bulk_import.py:98 -#: ipam/forms/bulk_import.py:118 ipam/forms/bulk_import.py:138 -#: ipam/forms/bulk_import.py:167 ipam/forms/bulk_import.py:253 -#: ipam/forms/bulk_import.py:289 ipam/forms/bulk_import.py:455 -#: virtualization/forms/bulk_import.py:70 +#: dcim/forms/bulk_import.py:1319 dcim/forms/bulk_import.py:1383 +#: ipam/forms/bulk_import.py:41 ipam/forms/bulk_import.py:70 +#: ipam/forms/bulk_import.py:98 ipam/forms/bulk_import.py:118 +#: ipam/forms/bulk_import.py:138 ipam/forms/bulk_import.py:167 +#: ipam/forms/bulk_import.py:253 ipam/forms/bulk_import.py:289 +#: ipam/forms/bulk_import.py:455 virtualization/forms/bulk_import.py:70 #: virtualization/forms/bulk_import.py:119 vpn/forms/bulk_import.py:63 #: wireless/forms/bulk_import.py:59 wireless/forms/bulk_import.py:101 msgid "Assigned tenant" @@ -1114,6 +1115,10 @@ msgstr "" msgid "ASN Count" msgstr "" +#: core/api/views.py:39 +msgid "This user does not have permission to synchronize this data source." +msgstr "" + #: core/choices.py:18 msgid "New" msgstr "" @@ -1242,9 +1247,9 @@ msgstr "" msgid "Ignore rules" msgstr "" -#: core/forms/filtersets.py:26 core/forms/model_forms.py:95 -#: extras/forms/model_forms.py:167 extras/forms/model_forms.py:464 -#: extras/forms/model_forms.py:517 extras/tables/tables.py:149 +#: core/forms/filtersets.py:26 core/forms/model_forms.py:96 +#: extras/forms/model_forms.py:167 extras/forms/model_forms.py:465 +#: extras/forms/model_forms.py:518 extras/tables/tables.py:149 #: extras/tables/tables.py:368 extras/tables/tables.py:403 #: templates/core/datasource.html:31 #: templates/dcim/device/render_config.html:19 @@ -1321,89 +1326,89 @@ msgstr "" msgid "User" msgstr "" -#: core/forms/model_forms.py:52 core/tables/data.py:46 +#: core/forms/model_forms.py:53 core/tables/data.py:46 #: templates/core/datafile.html:36 templates/extras/report/base.html:33 #: templates/extras/script/base.html:32 templates/extras/script_result.html:45 msgid "Source" msgstr "" -#: core/forms/model_forms.py:56 +#: core/forms/model_forms.py:57 msgid "Backend Parameters" msgstr "" -#: core/forms/model_forms.py:94 +#: core/forms/model_forms.py:95 msgid "File Upload" msgstr "" -#: core/forms/model_forms.py:106 +#: core/forms/model_forms.py:107 msgid "Cannot upload a file and sync from an existing file" msgstr "" -#: core/forms/model_forms.py:108 +#: core/forms/model_forms.py:109 msgid "Must upload a file or select a data file to sync" msgstr "" -#: core/forms/model_forms.py:147 templates/core/configrevision.html:43 +#: core/forms/model_forms.py:151 templates/core/configrevision.html:43 #: templates/dcim/rack_elevation_list.html:6 msgid "Rack Elevations" msgstr "" -#: core/forms/model_forms.py:148 dcim/choices.py:1413 +#: core/forms/model_forms.py:152 dcim/choices.py:1413 #: dcim/forms/bulk_edit.py:859 dcim/forms/bulk_edit.py:1242 #: dcim/forms/bulk_edit.py:1260 dcim/tables/racks.py:89 #: netbox/navigation/menu.py:276 netbox/navigation/menu.py:280 msgid "Power" msgstr "" -#: core/forms/model_forms.py:149 netbox/navigation/menu.py:142 +#: core/forms/model_forms.py:153 netbox/navigation/menu.py:142 #: templates/core/configrevision.html:79 msgid "IPAM" msgstr "" -#: core/forms/model_forms.py:150 netbox/navigation/menu.py:218 +#: core/forms/model_forms.py:154 netbox/navigation/menu.py:218 #: templates/core/configrevision.html:95 vpn/forms/bulk_edit.py:76 #: vpn/forms/filtersets.py:42 vpn/forms/model_forms.py:60 #: vpn/forms/model_forms.py:145 msgid "Security" msgstr "" -#: core/forms/model_forms.py:151 templates/core/configrevision.html:107 +#: core/forms/model_forms.py:155 templates/core/configrevision.html:107 msgid "Banners" msgstr "" -#: core/forms/model_forms.py:152 templates/core/configrevision.html:131 +#: core/forms/model_forms.py:156 templates/core/configrevision.html:131 msgid "Pagination" msgstr "" -#: core/forms/model_forms.py:153 extras/forms/model_forms.py:63 +#: core/forms/model_forms.py:157 extras/forms/model_forms.py:63 #: templates/core/configrevision.html:147 msgid "Validation" msgstr "" -#: core/forms/model_forms.py:154 templates/account/preferences.html:6 +#: core/forms/model_forms.py:158 templates/account/preferences.html:6 #: templates/core/configrevision.html:175 msgid "User Preferences" msgstr "" -#: core/forms/model_forms.py:155 dcim/forms/filtersets.py:658 +#: core/forms/model_forms.py:159 dcim/forms/filtersets.py:658 #: templates/core/configrevision.html:193 users/forms/model_forms.py:64 msgid "Miscellaneous" msgstr "" -#: core/forms/model_forms.py:158 +#: core/forms/model_forms.py:162 msgid "Config Revision" msgstr "" -#: core/forms/model_forms.py:197 +#: core/forms/model_forms.py:201 msgid "This parameter has been defined statically and cannot be modified." msgstr "" -#: core/forms/model_forms.py:205 +#: core/forms/model_forms.py:209 #, python-brace-format msgid "Current value: {value}" msgstr "" -#: core/forms/model_forms.py:207 +#: core/forms/model_forms.py:211 msgid " (default)" msgstr "" @@ -1640,7 +1645,7 @@ msgstr "" #: core/tables/jobs.py:10 dcim/tables/devicetypes.py:161 #: extras/tables/tables.py:174 extras/tables/tables.py:345 #: netbox/tables/tables.py:184 templates/dcim/virtualchassis_edit.html:53 -#: wireless/tables/wirelesslink.py:16 +#: utilities/forms/forms.py:74 wireless/tables/wirelesslink.py:16 msgid "ID" msgstr "" @@ -1672,6 +1677,10 @@ msgstr "" msgid "Position (U)" msgstr "" +#: dcim/api/serializers.py:671 +msgid "Deprecated in v3.6 in favor of `role`." +msgstr "" + #: dcim/choices.py:21 virtualization/choices.py:21 msgid "Staging" msgstr "" @@ -1748,7 +1757,7 @@ msgstr "" #: dcim/forms/bulk_import.py:778 dcim/forms/bulk_import.py:1033 #: dcim/forms/filtersets.py:226 dcim/forms/model_forms.py:73 #: dcim/forms/model_forms.py:94 dcim/forms/model_forms.py:172 -#: dcim/forms/model_forms.py:962 dcim/forms/model_forms.py:1303 +#: dcim/forms/model_forms.py:962 dcim/forms/model_forms.py:1392 #: dcim/forms/object_import.py:181 dcim/tables/devices.py:680 #: dcim/tables/devices.py:964 extras/tables/tables.py:181 #: ipam/tables/fhrp.py:59 ipam/tables/ip.py:374 ipam/tables/services.py:44 @@ -1859,7 +1868,7 @@ msgstr "" #: dcim/choices.py:796 dcim/choices.py:1022 dcim/forms/bulk_edit.py:1398 #: dcim/forms/filtersets.py:1233 dcim/forms/model_forms.py:888 -#: dcim/forms/model_forms.py:1197 netbox/navigation/menu.py:128 +#: dcim/forms/model_forms.py:1286 netbox/navigation/menu.py:128 #: netbox/navigation/menu.py:132 templates/dcim/interface.html:217 msgid "Wireless" msgstr "" @@ -2270,7 +2279,7 @@ msgstr "" #: dcim/filtersets.py:1172 dcim/filtersets.py:1264 ipam/filtersets.py:577 #: ipam/filtersets.py:807 ipam/filtersets.py:1026 -#: virtualization/filtersets.py:161 vpn/filtersets.py:351 +#: virtualization/filtersets.py:161 vpn/filtersets.py:362 msgid "Device (ID)" msgstr "" @@ -2279,7 +2288,7 @@ msgid "Rack (name)" msgstr "" #: dcim/filtersets.py:1270 ipam/filtersets.py:572 ipam/filtersets.py:802 -#: ipam/filtersets.py:1032 vpn/filtersets.py:346 +#: ipam/filtersets.py:1032 vpn/filtersets.py:357 msgid "Device (name)" msgstr "" @@ -2323,7 +2332,7 @@ msgstr "" #: dcim/filtersets.py:1448 dcim/forms/bulk_edit.py:1374 #: dcim/forms/bulk_import.py:836 dcim/forms/filtersets.py:1328 -#: dcim/forms/model_forms.py:1182 dcim/models/device_components.py:712 +#: dcim/forms/model_forms.py:1271 dcim/models/device_components.py:712 #: dcim/tables/devices.py:646 ipam/filtersets.py:282 ipam/filtersets.py:293 #: ipam/filtersets.py:449 ipam/filtersets.py:550 ipam/filtersets.py:561 #: ipam/forms/bulk_edit.py:226 ipam/forms/bulk_edit.py:281 @@ -2355,7 +2364,7 @@ msgstr "" msgid "VRF (RD)" msgstr "" -#: dcim/filtersets.py:1459 ipam/filtersets.py:967 vpn/filtersets.py:314 +#: dcim/filtersets.py:1459 ipam/filtersets.py:967 vpn/filtersets.py:325 msgid "L2VPN (ID)" msgstr "" @@ -2419,8 +2428,8 @@ msgid "Power panel (ID)" msgstr "" #: dcim/forms/bulk_create.py:40 extras/forms/filtersets.py:410 -#: extras/forms/model_forms.py:453 extras/forms/model_forms.py:504 -#: netbox/forms/base.py:82 netbox/forms/mixins.py:81 +#: extras/forms/model_forms.py:454 extras/forms/model_forms.py:505 +#: netbox/forms/base.py:77 netbox/forms/mixins.py:81 #: netbox/tables/columns.py:448 #: templates/circuits/inc/circuit_termination.html:119 #: templates/generic/bulk_edit.html:81 templates/inc/panels/tags.html:5 @@ -2497,7 +2506,7 @@ msgstr "" #: dcim/forms/bulk_import.py:1021 dcim/forms/filtersets.py:299 #: dcim/forms/filtersets.py:704 dcim/forms/filtersets.py:1417 #: dcim/forms/model_forms.py:224 dcim/forms/model_forms.py:970 -#: dcim/forms/model_forms.py:1311 dcim/forms/object_import.py:186 +#: dcim/forms/model_forms.py:1400 dcim/forms/object_import.py:186 #: dcim/tables/devices.py:202 dcim/tables/devices.py:837 #: dcim/tables/devices.py:948 dcim/tables/devicetypes.py:300 #: dcim/tables/racks.py:69 extras/filtersets.py:457 ipam/forms/bulk_edit.py:245 @@ -2633,8 +2642,9 @@ msgstr "" #: dcim/forms/filtersets.py:247 dcim/forms/filtersets.py:332 #: dcim/forms/filtersets.py:417 dcim/forms/filtersets.py:543 #: dcim/forms/filtersets.py:652 dcim/forms/filtersets.py:853 -#: dcim/forms/model_forms.py:596 dcim/forms/model_forms.py:1381 +#: dcim/forms/model_forms.py:596 dcim/forms/model_forms.py:1470 #: templates/dcim/device_edit.html:20 templates/dcim/inventoryitem_edit.html:23 +#: templates/dcim/inventoryitemtemplate_edit.html:22 msgid "Hardware" msgstr "" @@ -2649,7 +2659,7 @@ msgstr "" #: dcim/forms/filtersets.py:858 dcim/forms/filtersets.py:1423 #: dcim/forms/model_forms.py:274 dcim/forms/model_forms.py:288 #: dcim/forms/model_forms.py:334 dcim/forms/model_forms.py:374 -#: dcim/forms/model_forms.py:975 dcim/forms/model_forms.py:1316 +#: dcim/forms/model_forms.py:975 dcim/forms/model_forms.py:1405 #: dcim/forms/object_import.py:192 dcim/tables/devices.py:129 #: dcim/tables/devices.py:205 dcim/tables/devices.py:951 #: dcim/tables/devicetypes.py:81 dcim/tables/devicetypes.py:304 @@ -2759,8 +2769,8 @@ msgstr "" #: dcim/forms/filtersets.py:1401 dcim/forms/filtersets.py:1412 #: dcim/forms/filtersets.py:1476 dcim/forms/filtersets.py:1500 #: dcim/forms/filtersets.py:1524 dcim/forms/model_forms.py:562 -#: dcim/forms/model_forms.py:760 dcim/forms/model_forms.py:1011 -#: dcim/forms/model_forms.py:1460 dcim/forms/object_create.py:256 +#: dcim/forms/model_forms.py:760 dcim/forms/model_forms.py:1100 +#: dcim/forms/model_forms.py:1549 dcim/forms/object_create.py:256 #: dcim/tables/connections.py:22 dcim/tables/connections.py:41 #: dcim/tables/connections.py:60 dcim/tables/devices.py:318 #: dcim/tables/devices.py:383 dcim/tables/devices.py:427 @@ -2898,8 +2908,8 @@ msgid "Allocated power draw (watts)" msgstr "" #: dcim/forms/bulk_edit.py:968 dcim/forms/bulk_import.py:731 -#: dcim/forms/model_forms.py:855 dcim/forms/model_forms.py:1083 -#: dcim/forms/model_forms.py:1368 dcim/forms/object_import.py:60 +#: dcim/forms/model_forms.py:855 dcim/forms/model_forms.py:1172 +#: dcim/forms/model_forms.py:1457 dcim/forms/object_import.py:60 msgid "Power port" msgstr "" @@ -2932,7 +2942,7 @@ msgid "Wireless role" msgstr "" #: dcim/forms/bulk_edit.py:1178 dcim/forms/model_forms.py:595 -#: dcim/forms/model_forms.py:1026 dcim/tables/devices.py:341 +#: dcim/forms/model_forms.py:1115 dcim/tables/devices.py:341 #: templates/dcim/consoleport.html:27 templates/dcim/consoleserverport.html:27 #: templates/dcim/frontport.html:27 templates/dcim/interface.html:35 #: templates/dcim/module.html:51 templates/dcim/modulebay.html:57 @@ -2946,7 +2956,7 @@ msgstr "" msgid "LAG" msgstr "" -#: dcim/forms/bulk_edit.py:1310 dcim/forms/model_forms.py:1110 +#: dcim/forms/bulk_edit.py:1310 dcim/forms/model_forms.py:1199 msgid "Virtual device contexts" msgstr "" @@ -2970,37 +2980,37 @@ msgstr "" msgid "Mode" msgstr "" -#: dcim/forms/bulk_edit.py:1353 dcim/forms/model_forms.py:1159 +#: dcim/forms/bulk_edit.py:1353 dcim/forms/model_forms.py:1248 #: ipam/forms/bulk_import.py:177 ipam/forms/filtersets.py:479 #: ipam/models/vlans.py:84 virtualization/forms/bulk_edit.py:239 #: virtualization/forms/model_forms.py:324 msgid "VLAN group" msgstr "" -#: dcim/forms/bulk_edit.py:1361 dcim/forms/model_forms.py:1164 +#: dcim/forms/bulk_edit.py:1361 dcim/forms/model_forms.py:1253 #: dcim/tables/devices.py:603 virtualization/forms/bulk_edit.py:247 #: virtualization/forms/model_forms.py:329 msgid "Untagged VLAN" msgstr "" -#: dcim/forms/bulk_edit.py:1369 dcim/forms/model_forms.py:1173 +#: dcim/forms/bulk_edit.py:1369 dcim/forms/model_forms.py:1262 #: dcim/tables/devices.py:609 virtualization/forms/bulk_edit.py:255 #: virtualization/forms/model_forms.py:338 msgid "Tagged VLANs" msgstr "" -#: dcim/forms/bulk_edit.py:1379 dcim/forms/model_forms.py:1146 +#: dcim/forms/bulk_edit.py:1379 dcim/forms/model_forms.py:1235 msgid "Wireless LAN group" msgstr "" -#: dcim/forms/bulk_edit.py:1384 dcim/forms/model_forms.py:1151 +#: dcim/forms/bulk_edit.py:1384 dcim/forms/model_forms.py:1240 #: dcim/tables/devices.py:639 netbox/navigation/menu.py:134 #: templates/dcim/interface.html:289 wireless/tables/wirelesslan.py:24 msgid "Wireless LANs" msgstr "" #: dcim/forms/bulk_edit.py:1393 dcim/forms/filtersets.py:1231 -#: dcim/forms/model_forms.py:1192 ipam/forms/bulk_edit.py:270 +#: dcim/forms/model_forms.py:1281 ipam/forms/bulk_edit.py:270 #: ipam/forms/bulk_edit.py:361 ipam/forms/filtersets.py:166 #: templates/dcim/interface.html:126 templates/ipam/prefix.html:96 #: virtualization/forms/model_forms.py:352 @@ -3008,22 +3018,22 @@ msgid "Addressing" msgstr "" #: dcim/forms/bulk_edit.py:1394 dcim/forms/filtersets.py:651 -#: dcim/forms/model_forms.py:1193 virtualization/forms/model_forms.py:353 +#: dcim/forms/model_forms.py:1282 virtualization/forms/model_forms.py:353 msgid "Operation" msgstr "" #: dcim/forms/bulk_edit.py:1395 dcim/forms/filtersets.py:1232 -#: dcim/forms/model_forms.py:887 dcim/forms/model_forms.py:1195 +#: dcim/forms/model_forms.py:887 dcim/forms/model_forms.py:1284 msgid "PoE" msgstr "" -#: dcim/forms/bulk_edit.py:1396 dcim/forms/model_forms.py:1194 +#: dcim/forms/bulk_edit.py:1396 dcim/forms/model_forms.py:1283 #: templates/dcim/interface.html:101 virtualization/forms/bulk_edit.py:266 #: virtualization/forms/model_forms.py:354 msgid "Related Interfaces" msgstr "" -#: dcim/forms/bulk_edit.py:1397 dcim/forms/model_forms.py:1196 +#: dcim/forms/bulk_edit.py:1397 dcim/forms/model_forms.py:1285 #: virtualization/forms/bulk_edit.py:267 #: virtualization/forms/model_forms.py:355 msgid "802.1Q Switching" @@ -3143,7 +3153,8 @@ msgstr "" msgid "Limit platform assignments to this manufacturer" msgstr "" -#: dcim/forms/bulk_import.py:421 tenancy/forms/bulk_import.py:106 +#: dcim/forms/bulk_import.py:421 dcim/forms/bulk_import.py:1376 +#: tenancy/forms/bulk_import.py:106 msgid "Assigned role" msgstr "" @@ -3272,13 +3283,13 @@ msgstr "" msgid "Electrical phase (for three-phase circuits)" msgstr "" -#: dcim/forms/bulk_import.py:782 dcim/forms/model_forms.py:1121 +#: dcim/forms/bulk_import.py:782 dcim/forms/model_forms.py:1210 #: virtualization/forms/bulk_import.py:155 #: virtualization/forms/model_forms.py:308 msgid "Parent interface" msgstr "" -#: dcim/forms/bulk_import.py:789 dcim/forms/model_forms.py:1129 +#: dcim/forms/bulk_import.py:789 dcim/forms/model_forms.py:1218 #: virtualization/forms/bulk_import.py:162 #: virtualization/forms/model_forms.py:316 msgid "Bridged interface" @@ -3341,7 +3352,7 @@ msgid "VDC {vdc} is not assigned to device {device}" msgstr "" #: dcim/forms/bulk_import.py:896 dcim/forms/model_forms.py:900 -#: dcim/forms/model_forms.py:1376 dcim/forms/object_import.py:122 +#: dcim/forms/model_forms.py:1465 dcim/forms/object_import.py:122 msgid "Rear port" msgstr "" @@ -3572,14 +3583,14 @@ msgstr "" msgid "Connection" msgstr "" -#: dcim/forms/filtersets.py:1245 dcim/forms/model_forms.py:1484 +#: dcim/forms/filtersets.py:1245 dcim/forms/model_forms.py:1573 #: templates/dcim/virtualdevicecontext.html:16 msgid "Virtual Device Context" msgstr "" #: dcim/forms/filtersets.py:1248 extras/forms/bulk_edit.py:315 #: extras/forms/bulk_import.py:245 extras/forms/filtersets.py:479 -#: extras/forms/model_forms.py:557 extras/tables/tables.py:487 +#: extras/forms/model_forms.py:558 extras/tables/tables.py:487 #: templates/extras/journalentry.html:33 msgid "Kind" msgstr "" @@ -3588,7 +3599,7 @@ msgstr "" msgid "Mgmt only" msgstr "" -#: dcim/forms/filtersets.py:1289 dcim/forms/model_forms.py:1187 +#: dcim/forms/filtersets.py:1289 dcim/forms/model_forms.py:1276 #: dcim/models/device_components.py:630 templates/dcim/interface.html:134 msgid "WWN" msgstr "" @@ -3695,18 +3706,52 @@ msgstr "" msgid "Characteristics" msgstr "" -#: dcim/forms/model_forms.py:1137 +#: dcim/forms/model_forms.py:986 +msgid "Console port template" +msgstr "" + +#: dcim/forms/model_forms.py:994 +msgid "Console server port template" +msgstr "" + +#: dcim/forms/model_forms.py:1002 +msgid "Front port template" +msgstr "" + +#: dcim/forms/model_forms.py:1010 +msgid "Interface template" +msgstr "" + +#: dcim/forms/model_forms.py:1018 +msgid "Power outlet template" +msgstr "" + +#: dcim/forms/model_forms.py:1026 +msgid "Power port template" +msgstr "" + +#: dcim/forms/model_forms.py:1034 +msgid "Rear port template" +msgstr "" + +#: dcim/forms/model_forms.py:1087 dcim/forms/model_forms.py:1521 +msgid "An InventoryItem can only be assigned to a single component." +msgstr "" + +#: dcim/forms/model_forms.py:1226 msgid "LAG interface" msgstr "" -#: dcim/forms/model_forms.py:1191 dcim/forms/model_forms.py:1352 +#: dcim/forms/model_forms.py:1280 dcim/forms/model_forms.py:1441 #: dcim/tables/connections.py:65 ipam/forms/bulk_import.py:317 #: ipam/forms/model_forms.py:270 ipam/forms/model_forms.py:279 #: ipam/tables/fhrp.py:64 ipam/tables/ip.py:368 ipam/tables/vlans.py:165 #: templates/circuits/inc/circuit_termination.html:78 #: templates/dcim/frontport.html:113 templates/dcim/interface.html:27 #: templates/dcim/interface.html:190 templates/dcim/interface.html:322 -#: templates/dcim/inventoryitem_edit.html:54 templates/dcim/rearport.html:109 +#: templates/dcim/inventoryitem_edit.html:54 +#: templates/dcim/inventoryitemtemplate_edit.html:51 +#: templates/dcim/rearport.html:109 #: templates/ipam/fhrpgroupassignment_edit.html:11 #: templates/virtualization/vminterface.html:19 #: templates/vpn/tunneltermination.html:32 @@ -3719,52 +3764,49 @@ msgstr "" msgid "Interface" msgstr "" -#: dcim/forms/model_forms.py:1285 +#: dcim/forms/model_forms.py:1374 msgid "Child Device" msgstr "" -#: dcim/forms/model_forms.py:1286 +#: dcim/forms/model_forms.py:1375 msgid "" "Child devices must first be created and assigned to the site and rack of the " "parent device." msgstr "" -#: dcim/forms/model_forms.py:1328 +#: dcim/forms/model_forms.py:1417 msgid "Console port" msgstr "" -#: dcim/forms/model_forms.py:1336 +#: dcim/forms/model_forms.py:1425 msgid "Console server port" msgstr "" -#: dcim/forms/model_forms.py:1344 +#: dcim/forms/model_forms.py:1433 msgid "Front port" msgstr "" -#: dcim/forms/model_forms.py:1360 +#: dcim/forms/model_forms.py:1449 msgid "Power outlet" msgstr "" -#: dcim/forms/model_forms.py:1380 templates/dcim/inventoryitem.html:17 +#: dcim/forms/model_forms.py:1469 templates/dcim/inventoryitem.html:17 #: templates/dcim/inventoryitem_edit.html:10 +#: templates/dcim/inventoryitemtemplate_edit.html:10 msgid "Inventory Item" msgstr "" -#: dcim/forms/model_forms.py:1432 -msgid "An InventoryItem can only be assigned to a single component." -msgstr "" - -#: dcim/forms/model_forms.py:1446 templates/dcim/inventoryitemrole.html:15 +#: dcim/forms/model_forms.py:1535 templates/dcim/inventoryitemrole.html:15 msgid "Inventory Item Role" msgstr "" -#: dcim/forms/model_forms.py:1466 templates/dcim/device.html:195 +#: dcim/forms/model_forms.py:1555 templates/dcim/device.html:195 #: templates/dcim/virtualdevicecontext.html:33 #: templates/virtualization/virtualmachine.html:51 msgid "Primary IPv4" msgstr "" -#: dcim/forms/model_forms.py:1475 templates/dcim/device.html:211 +#: dcim/forms/model_forms.py:1564 templates/dcim/device.html:211 #: templates/dcim/virtualdevicecontext.html:44 #: templates/virtualization/virtualmachine.html:67 msgid "Primary IPv6" @@ -5256,6 +5298,7 @@ msgstr "" #: dcim/tables/connections.py:27 templates/dcim/consoleport.html:18 #: templates/dcim/consoleserverport.html:75 templates/dcim/frontport.html:119 #: templates/dcim/inventoryitem_edit.html:39 +#: templates/dcim/inventoryitemtemplate_edit.html:36 msgid "Console Port" msgstr "" @@ -5266,8 +5309,9 @@ msgid "Reachable" msgstr "" #: dcim/tables/connections.py:46 dcim/tables/devices.py:533 -#: templates/dcim/inventoryitem_edit.html:64 templates/dcim/poweroutlet.html:47 -#: templates/dcim/powerport.html:18 +#: templates/dcim/inventoryitem_edit.html:64 +#: templates/dcim/inventoryitemtemplate_edit.html:61 +#: templates/dcim/poweroutlet.html:47 templates/dcim/powerport.html:18 msgid "Power Port" msgstr "" @@ -5285,7 +5329,7 @@ msgid "VMs" msgstr "" #: dcim/tables/devices.py:133 dcim/tables/devices.py:249 -#: extras/forms/model_forms.py:515 templates/dcim/device.html:114 +#: extras/forms/model_forms.py:516 templates/dcim/device.html:114 #: templates/dcim/device/render_config.html:11 #: templates/dcim/device/render_config.html:15 #: templates/dcim/devicerole.html:47 templates/dcim/platform.html:44 @@ -5350,7 +5394,7 @@ msgstr "" #: dcim/tables/devices.py:279 dcim/tables/devices.py:1091 #: dcim/tables/devicetypes.py:125 dcim/views.py:1005 dcim/views.py:1244 -#: dcim/views.py:1930 netbox/navigation/menu.py:82 +#: dcim/views.py:1932 netbox/navigation/menu.py:82 #: netbox/navigation/menu.py:238 templates/dcim/device/base.html:37 #: templates/dcim/device_list.html:43 templates/dcim/devicetype/base.html:34 #: templates/dcim/module.html:34 templates/dcim/moduletype/base.html:34 @@ -5441,7 +5485,7 @@ msgid "VDCs" msgstr "" #: dcim/tables/devices.py:651 dcim/tables/devicetypes.py:48 -#: dcim/tables/devicetypes.py:140 dcim/views.py:1080 dcim/views.py:2023 +#: dcim/tables/devicetypes.py:140 dcim/views.py:1080 dcim/views.py:2025 #: netbox/navigation/menu.py:91 templates/dcim/device/base.html:52 #: templates/dcim/device_list.html:71 templates/dcim/devicetype/base.html:49 #: templates/dcim/inc/panels/inventory_items.html:5 @@ -5454,6 +5498,7 @@ msgstr "" #: templates/dcim/consoleport.html:81 templates/dcim/consoleserverport.html:81 #: templates/dcim/frontport.html:53 templates/dcim/frontport.html:125 #: templates/dcim/interface.html:196 templates/dcim/inventoryitem_edit.html:69 +#: templates/dcim/inventoryitemtemplate_edit.html:66 #: templates/dcim/rearport.html:18 templates/dcim/rearport.html:115 msgid "Rear Port" msgstr "" @@ -5493,7 +5538,7 @@ msgid "Module Types" msgstr "" #: dcim/tables/devicetypes.py:53 extras/forms/filtersets.py:379 -#: extras/forms/model_forms.py:423 netbox/navigation/menu.py:66 +#: extras/forms/model_forms.py:424 netbox/navigation/menu.py:66 msgid "Platforms" msgstr "" @@ -5514,7 +5559,7 @@ msgid "Instances" msgstr "" #: dcim/tables/devicetypes.py:113 dcim/views.py:945 dcim/views.py:1184 -#: dcim/views.py:1870 netbox/navigation/menu.py:85 +#: dcim/views.py:1872 netbox/navigation/menu.py:85 #: templates/dcim/device/base.html:25 templates/dcim/device_list.html:15 #: templates/dcim/devicetype/base.html:22 templates/dcim/module.html:22 #: templates/dcim/moduletype/base.html:22 @@ -5522,7 +5567,7 @@ msgid "Console Ports" msgstr "" #: dcim/tables/devicetypes.py:116 dcim/views.py:960 dcim/views.py:1199 -#: dcim/views.py:1885 netbox/navigation/menu.py:86 +#: dcim/views.py:1887 netbox/navigation/menu.py:86 #: templates/dcim/device/base.html:28 templates/dcim/device_list.html:22 #: templates/dcim/devicetype/base.html:25 templates/dcim/module.html:25 #: templates/dcim/moduletype/base.html:25 @@ -5530,7 +5575,7 @@ msgid "Console Server Ports" msgstr "" #: dcim/tables/devicetypes.py:119 dcim/views.py:975 dcim/views.py:1214 -#: dcim/views.py:1900 netbox/navigation/menu.py:87 +#: dcim/views.py:1902 netbox/navigation/menu.py:87 #: templates/dcim/device/base.html:31 templates/dcim/device_list.html:29 #: templates/dcim/devicetype/base.html:28 templates/dcim/module.html:28 #: templates/dcim/moduletype/base.html:28 @@ -5538,7 +5583,7 @@ msgid "Power Ports" msgstr "" #: dcim/tables/devicetypes.py:122 dcim/views.py:990 dcim/views.py:1229 -#: dcim/views.py:1915 netbox/navigation/menu.py:88 +#: dcim/views.py:1917 netbox/navigation/menu.py:88 #: templates/dcim/device/base.html:34 templates/dcim/device_list.html:36 #: templates/dcim/devicetype/base.html:31 templates/dcim/module.html:31 #: templates/dcim/moduletype/base.html:31 @@ -5546,27 +5591,27 @@ msgid "Power Outlets" msgstr "" #: dcim/tables/devicetypes.py:128 dcim/views.py:1020 dcim/views.py:1259 -#: dcim/views.py:1951 netbox/navigation/menu.py:83 +#: dcim/views.py:1953 netbox/navigation/menu.py:83 #: templates/dcim/device/base.html:40 templates/dcim/devicetype/base.html:37 #: templates/dcim/module.html:37 templates/dcim/moduletype/base.html:37 msgid "Front Ports" msgstr "" #: dcim/tables/devicetypes.py:131 dcim/views.py:1035 dcim/views.py:1274 -#: dcim/views.py:1966 netbox/navigation/menu.py:84 +#: dcim/views.py:1968 netbox/navigation/menu.py:84 #: templates/dcim/device/base.html:43 templates/dcim/device_list.html:50 #: templates/dcim/devicetype/base.html:40 templates/dcim/module.html:40 #: templates/dcim/moduletype/base.html:40 msgid "Rear Ports" msgstr "" -#: dcim/tables/devicetypes.py:134 dcim/views.py:1065 dcim/views.py:2004 +#: dcim/tables/devicetypes.py:134 dcim/views.py:1065 dcim/views.py:2006 #: netbox/navigation/menu.py:90 templates/dcim/device/base.html:49 #: templates/dcim/device_list.html:57 templates/dcim/devicetype/base.html:46 msgid "Device Bays" msgstr "" -#: dcim/tables/devicetypes.py:137 dcim/views.py:1050 dcim/views.py:1985 +#: dcim/tables/devicetypes.py:137 dcim/views.py:1050 dcim/views.py:1987 #: netbox/navigation/menu.py:89 templates/dcim/device/base.html:46 #: templates/dcim/device_list.html:64 templates/dcim/devicetype/base.html:43 msgid "Module Bays" @@ -5612,7 +5657,7 @@ msgid "Max Weight" msgstr "" #: dcim/tables/sites.py:30 dcim/tables/sites.py:57 -#: extras/forms/filtersets.py:359 extras/forms/model_forms.py:403 +#: extras/forms/filtersets.py:359 extras/forms/model_forms.py:404 #: ipam/forms/bulk_edit.py:128 ipam/forms/model_forms.py:152 #: ipam/tables/asn.py:66 netbox/navigation/menu.py:16 #: netbox/navigation/menu.py:18 @@ -5636,17 +5681,17 @@ msgstr "" msgid "Non-Racked Devices" msgstr "" -#: dcim/views.py:2036 extras/forms/model_forms.py:463 +#: dcim/views.py:2038 extras/forms/model_forms.py:464 #: templates/extras/configcontext.html:10 #: virtualization/forms/model_forms.py:228 virtualization/views.py:408 msgid "Config Context" msgstr "" -#: dcim/views.py:2046 virtualization/views.py:418 +#: dcim/views.py:2048 virtualization/views.py:418 msgid "Render Config" msgstr "" -#: dcim/views.py:2974 ipam/tables/ip.py:233 +#: dcim/views.py:2976 ipam/tables/ip.py:233 msgid "Children" msgstr "" @@ -5890,7 +5935,7 @@ msgid "White" msgstr "" #: extras/choices.py:306 extras/forms/model_forms.py:235 -#: extras/forms/model_forms.py:321 templates/extras/webhook.html:11 +#: extras/forms/model_forms.py:322 templates/extras/webhook.html:11 msgid "Webhook" msgstr "" @@ -6266,7 +6311,7 @@ msgid "Choices" msgstr "" #: extras/forms/filtersets.py:141 extras/forms/filtersets.py:327 -#: extras/forms/filtersets.py:417 extras/forms/model_forms.py:458 +#: extras/forms/filtersets.py:417 extras/forms/model_forms.py:459 #: templates/core/job.html:86 templates/extras/configcontext.html:86 #: templates/extras/eventrule.html:111 msgid "Data" @@ -6286,7 +6331,7 @@ msgstr "" msgid "HTTP content type" msgstr "" -#: extras/forms/filtersets.py:254 extras/forms/model_forms.py:271 +#: extras/forms/filtersets.py:254 extras/forms/model_forms.py:272 #: templates/extras/eventrule.html:46 msgid "Events" msgstr "" @@ -6311,7 +6356,7 @@ msgstr "" msgid "Job starts" msgstr "" -#: extras/forms/filtersets.py:306 extras/forms/model_forms.py:290 +#: extras/forms/filtersets.py:306 extras/forms/model_forms.py:291 msgid "Job terminations" msgstr "" @@ -6323,44 +6368,44 @@ msgstr "" msgid "Allowed object type" msgstr "" -#: extras/forms/filtersets.py:349 extras/forms/model_forms.py:393 +#: extras/forms/filtersets.py:349 extras/forms/model_forms.py:394 #: netbox/navigation/menu.py:19 msgid "Regions" msgstr "" -#: extras/forms/filtersets.py:354 extras/forms/model_forms.py:398 +#: extras/forms/filtersets.py:354 extras/forms/model_forms.py:399 msgid "Site groups" msgstr "" -#: extras/forms/filtersets.py:364 extras/forms/model_forms.py:408 +#: extras/forms/filtersets.py:364 extras/forms/model_forms.py:409 #: netbox/navigation/menu.py:21 msgid "Locations" msgstr "" -#: extras/forms/filtersets.py:369 extras/forms/model_forms.py:413 +#: extras/forms/filtersets.py:369 extras/forms/model_forms.py:414 msgid "Device types" msgstr "" -#: extras/forms/filtersets.py:374 extras/forms/model_forms.py:418 +#: extras/forms/filtersets.py:374 extras/forms/model_forms.py:419 msgid "Roles" msgstr "" -#: extras/forms/filtersets.py:384 extras/forms/model_forms.py:428 +#: extras/forms/filtersets.py:384 extras/forms/model_forms.py:429 msgid "Cluster types" msgstr "" -#: extras/forms/filtersets.py:390 extras/forms/model_forms.py:433 +#: extras/forms/filtersets.py:390 extras/forms/model_forms.py:434 msgid "Cluster groups" msgstr "" -#: extras/forms/filtersets.py:395 extras/forms/model_forms.py:438 +#: extras/forms/filtersets.py:395 extras/forms/model_forms.py:439 #: netbox/navigation/menu.py:243 netbox/navigation/menu.py:245 #: templates/virtualization/clustertype.html:33 #: virtualization/tables/clusters.py:23 virtualization/tables/clusters.py:45 msgid "Clusters" msgstr "" -#: extras/forms/filtersets.py:400 extras/forms/model_forms.py:443 +#: extras/forms/filtersets.py:400 extras/forms/model_forms.py:444 msgid "Tenant groups" msgstr "" @@ -6378,7 +6423,7 @@ msgstr "" msgid "Time" msgstr "" -#: extras/forms/filtersets.py:504 extras/forms/model_forms.py:273 +#: extras/forms/filtersets.py:504 extras/forms/model_forms.py:274 #: extras/tables/tables.py:445 templates/extras/eventrule.html:90 #: templates/extras/objectchange.html:50 msgid "Action" @@ -6439,7 +6484,7 @@ msgid "" "Jinja2 template code for the link URL. Reference the object as {example}." msgstr "" -#: extras/forms/model_forms.py:160 extras/forms/model_forms.py:509 +#: extras/forms/model_forms.py:160 extras/forms/model_forms.py:510 msgid "Template code" msgstr "" @@ -6451,11 +6496,11 @@ msgstr "" msgid "Rendering" msgstr "" -#: extras/forms/model_forms.py:182 extras/forms/model_forms.py:534 +#: extras/forms/model_forms.py:182 extras/forms/model_forms.py:535 msgid "Template content is populated from the remote source selected below." msgstr "" -#: extras/forms/model_forms.py:189 extras/forms/model_forms.py:541 +#: extras/forms/model_forms.py:189 extras/forms/model_forms.py:542 msgid "Must specify either local content or a data file" msgstr "" @@ -6486,55 +6531,55 @@ msgid "" "\">JSON format." msgstr "" -#: extras/forms/model_forms.py:270 templates/extras/eventrule.html:11 +#: extras/forms/model_forms.py:271 templates/extras/eventrule.html:11 msgid "Event Rule" msgstr "" -#: extras/forms/model_forms.py:272 templates/extras/eventrule.html:78 +#: extras/forms/model_forms.py:273 templates/extras/eventrule.html:78 msgid "Conditions" msgstr "" -#: extras/forms/model_forms.py:286 +#: extras/forms/model_forms.py:287 msgid "Creations" msgstr "" -#: extras/forms/model_forms.py:287 +#: extras/forms/model_forms.py:288 msgid "Updates" msgstr "" -#: extras/forms/model_forms.py:288 +#: extras/forms/model_forms.py:289 msgid "Deletions" msgstr "" -#: extras/forms/model_forms.py:289 +#: extras/forms/model_forms.py:290 msgid "Job executions" msgstr "" -#: extras/forms/model_forms.py:375 users/forms/model_forms.py:286 +#: extras/forms/model_forms.py:376 users/forms/model_forms.py:286 msgid "Object types" msgstr "" -#: extras/forms/model_forms.py:448 netbox/navigation/menu.py:40 +#: extras/forms/model_forms.py:449 netbox/navigation/menu.py:40 #: tenancy/tables/tenants.py:22 msgid "Tenants" msgstr "" -#: extras/forms/model_forms.py:465 ipam/forms/filtersets.py:141 +#: extras/forms/model_forms.py:466 ipam/forms/filtersets.py:141 #: ipam/forms/filtersets.py:527 templates/extras/configcontext.html:62 #: templates/ipam/ipaddress.html:62 templates/ipam/vlan_edit.html:30 #: tenancy/forms/filtersets.py:86 users/forms/model_forms.py:324 msgid "Assignment" msgstr "" -#: extras/forms/model_forms.py:491 +#: extras/forms/model_forms.py:492 msgid "Data is populated from the remote source selected below." msgstr "" -#: extras/forms/model_forms.py:497 +#: extras/forms/model_forms.py:498 msgid "Must specify either local data or a data file" msgstr "" -#: extras/forms/model_forms.py:516 templates/core/datafile.html:65 +#: extras/forms/model_forms.py:517 templates/core/datafile.html:65 msgid "Content" msgstr "" @@ -7545,19 +7590,19 @@ msgstr "" msgid "Invalid IP address format: {address}" msgstr "" -#: ipam/filtersets.py:47 vpn/filtersets.py:276 +#: ipam/filtersets.py:47 vpn/filtersets.py:287 msgid "Import target" msgstr "" -#: ipam/filtersets.py:53 vpn/filtersets.py:282 +#: ipam/filtersets.py:53 vpn/filtersets.py:293 msgid "Import target (name)" msgstr "" -#: ipam/filtersets.py:58 vpn/filtersets.py:287 +#: ipam/filtersets.py:58 vpn/filtersets.py:298 msgid "Export target" msgstr "" -#: ipam/filtersets.py:64 vpn/filtersets.py:293 +#: ipam/filtersets.py:64 vpn/filtersets.py:304 msgid "Export target (name)" msgstr "" @@ -7607,11 +7652,11 @@ msgstr "" msgid "Mask length" msgstr "" -#: ipam/filtersets.py:339 vpn/filtersets.py:399 +#: ipam/filtersets.py:339 vpn/filtersets.py:410 msgid "VLAN (ID)" msgstr "" -#: ipam/filtersets.py:343 vpn/filtersets.py:394 +#: ipam/filtersets.py:343 vpn/filtersets.py:405 msgid "VLAN number (1-4094)" msgstr "" @@ -7630,25 +7675,25 @@ msgid "Parent prefix" msgstr "" #: ipam/filtersets.py:582 ipam/filtersets.py:812 ipam/filtersets.py:1042 -#: vpn/filtersets.py:357 +#: vpn/filtersets.py:368 msgid "Virtual machine (name)" msgstr "" #: ipam/filtersets.py:587 ipam/filtersets.py:817 ipam/filtersets.py:1036 #: virtualization/filtersets.py:278 virtualization/filtersets.py:317 -#: vpn/filtersets.py:362 +#: vpn/filtersets.py:373 msgid "Virtual machine (ID)" msgstr "" -#: ipam/filtersets.py:593 vpn/filtersets.py:97 vpn/filtersets.py:368 +#: ipam/filtersets.py:593 vpn/filtersets.py:97 vpn/filtersets.py:379 msgid "Interface (name)" msgstr "" -#: ipam/filtersets.py:598 vpn/filtersets.py:102 vpn/filtersets.py:373 +#: ipam/filtersets.py:598 vpn/filtersets.py:102 vpn/filtersets.py:384 msgid "Interface (ID)" msgstr "" -#: ipam/filtersets.py:604 vpn/filtersets.py:108 vpn/filtersets.py:379 +#: ipam/filtersets.py:604 vpn/filtersets.py:108 vpn/filtersets.py:390 msgid "VM interface (name)" msgstr "" @@ -8912,15 +8957,17 @@ msgstr "" msgid "Object type(s)" msgstr "" -#: netbox/forms/base.py:77 -msgid "Id" +#: netbox/forms/base.py:81 +msgid "" +"Tag slugs separated by commas, encased with double quotes (e.g. \"tag1,tag2," +"tag3\")" msgstr "" -#: netbox/forms/base.py:116 +#: netbox/forms/base.py:111 msgid "Add tags" msgstr "" -#: netbox/forms/base.py:121 +#: netbox/forms/base.py:116 msgid "Remove tags" msgstr "" @@ -9192,8 +9239,9 @@ msgstr "" #: netbox/navigation/menu.py:310 #: templates/circuits/circuittermination_edit.html:53 #: templates/dcim/cable_edit.html:77 templates/dcim/device_edit.html:103 -#: templates/dcim/inventoryitem_edit.html:102 templates/dcim/rack_edit.html:81 -#: templates/dcim/virtualchassis_add.html:31 +#: templates/dcim/inventoryitem_edit.html:102 +#: templates/dcim/inventoryitemtemplate_edit.html:99 +#: templates/dcim/rack_edit.html:81 templates/dcim/virtualchassis_add.html:31 #: templates/dcim/virtualchassis_edit.html:41 #: templates/generic/bulk_edit.html:92 templates/htmx/form.html:32 #: templates/inc/panels/custom_fields.html:7 @@ -9420,31 +9468,31 @@ msgstr "" msgid "Cannot delete stores from registry" msgstr "" -#: netbox/settings.py:724 +#: netbox/settings.py:727 msgid "English" msgstr "" -#: netbox/settings.py:725 +#: netbox/settings.py:728 msgid "Spanish" msgstr "" -#: netbox/settings.py:726 +#: netbox/settings.py:729 msgid "French" msgstr "" -#: netbox/settings.py:727 +#: netbox/settings.py:730 msgid "Japanese" msgstr "" -#: netbox/settings.py:728 +#: netbox/settings.py:731 msgid "Portuguese" msgstr "" -#: netbox/settings.py:729 +#: netbox/settings.py:732 msgid "Russian" msgstr "" -#: netbox/settings.py:730 +#: netbox/settings.py:733 msgid "Turkish" msgstr "" @@ -9904,6 +9952,7 @@ msgstr "" #: templates/dcim/consoleport.html:78 templates/dcim/consoleserverport.html:78 #: templates/dcim/frontport.html:18 templates/dcim/frontport.html:122 #: templates/dcim/interface.html:193 templates/dcim/inventoryitem_edit.html:49 +#: templates/dcim/inventoryitemtemplate_edit.html:46 #: templates/dcim/rearport.html:112 msgid "Front Port" msgstr "" @@ -10144,6 +10193,7 @@ msgstr "" #: templates/dcim/consoleport.html:75 templates/dcim/consoleserverport.html:18 #: templates/dcim/frontport.html:116 templates/dcim/inventoryitem_edit.html:44 +#: templates/dcim/inventoryitemtemplate_edit.html:41 msgid "Console Server Port" msgstr "" @@ -10572,11 +10622,13 @@ msgid "This will also delete all child inventory items of those listed" msgstr "" #: templates/dcim/inventoryitem_edit.html:33 +#: templates/dcim/inventoryitemtemplate_edit.html:30 msgid "Component Assignment" msgstr "" -#: templates/dcim/inventoryitem_edit.html:59 templates/dcim/poweroutlet.html:18 -#: templates/dcim/powerport.html:81 +#: templates/dcim/inventoryitem_edit.html:59 +#: templates/dcim/inventoryitemtemplate_edit.html:56 +#: templates/dcim/poweroutlet.html:18 templates/dcim/powerport.html:81 msgid "Power Outlet" msgstr "" @@ -12841,16 +12893,21 @@ msgstr "" msgid "Use regular expressions" msgstr "" -#: utilities/forms/forms.py:87 +#: utilities/forms/forms.py:76 +msgid "" +"Numeric ID of an existing object to update (if not creating a new object)" +msgstr "" + +#: utilities/forms/forms.py:93 #, python-brace-format msgid "Unrecognized header: {name}" msgstr "" -#: utilities/forms/forms.py:113 +#: utilities/forms/forms.py:119 msgid "Available Columns" msgstr "" -#: utilities/forms/forms.py:121 +#: utilities/forms/forms.py:127 msgid "Selected Columns" msgstr "" @@ -13020,7 +13077,7 @@ msgstr "" msgid "Testing" msgstr "" -#: utilities/testing/views.py:625 +#: utilities/testing/views.py:632 msgid "The test must define csv_update_data." msgstr "" @@ -13377,31 +13434,31 @@ msgstr "" msgid "Outside IP (ID)" msgstr "" -#: vpn/filtersets.py:235 +#: vpn/filtersets.py:142 vpn/filtersets.py:246 msgid "IKE policy (ID)" msgstr "" -#: vpn/filtersets.py:241 +#: vpn/filtersets.py:148 vpn/filtersets.py:252 msgid "IKE policy (name)" msgstr "" -#: vpn/filtersets.py:245 +#: vpn/filtersets.py:256 msgid "IPSec policy (ID)" msgstr "" -#: vpn/filtersets.py:251 +#: vpn/filtersets.py:262 msgid "IPSec policy (name)" msgstr "" -#: vpn/filtersets.py:320 +#: vpn/filtersets.py:331 msgid "L2VPN (slug)" msgstr "" -#: vpn/filtersets.py:384 +#: vpn/filtersets.py:395 msgid "VM Interface (ID)" msgstr "" -#: vpn/filtersets.py:390 +#: vpn/filtersets.py:401 msgid "VLAN (name)" msgstr "" From 6f36b8513c8f3db4adb3dd99bc50e2a39ef3b3d0 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 22 Apr 2024 21:51:08 -0500 Subject: [PATCH 15/33] Update changelog for #13874 --- docs/release-notes/version-3.7.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 64fdc7dfe..981d3c3c0 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -2,6 +2,11 @@ ## v3.7.7 (FUTURE) +### Bug Fixes + +* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display +* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on swap for device interface list display + --- ## v3.7.6 (2024-04-22) From 7b1b91b8eec4bbfb505b56df4d197c2ab80d978d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 22 Apr 2024 21:51:54 -0500 Subject: [PATCH 16/33] Correct wording for #13874 --- docs/release-notes/version-3.7.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 981d3c3c0..4e72acbd4 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -5,7 +5,7 @@ ### Bug Fixes * [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display -* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on swap for device interface list display +* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display --- From 85db007ff585370b03b2d88d8f43e6fa9b94bc8e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 22 Apr 2024 21:57:40 -0500 Subject: [PATCH 17/33] Update changelog for #14750 --- docs/release-notes/version-3.7.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 4e72acbd4..31b99fcdc 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -6,6 +6,8 @@ * [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display * [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display +* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices +* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination --- From 851b4cc4d3f49358b48c1d1e7b6bc2a51aa84473 Mon Sep 17 00:00:00 2001 From: Mattias Loverot Date: Mon, 29 Apr 2024 16:33:05 +0200 Subject: [PATCH 18/33] Added assigned_object_type in prefetch for api view IPAddressViewSet - fixes #15845 --- netbox/ipam/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 62e2b9eca..c5a9331ab 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -119,7 +119,7 @@ class IPRangeViewSet(NetBoxModelViewSet): class IPAddressViewSet(NetBoxModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object', 'assigned_object_type' ) serializer_class = serializers.IPAddressSerializer filterset_class = filtersets.IPAddressFilterSet From 9691bb29b6ce8c3fb4cebcd6d902fd5977e0d350 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 29 Apr 2024 09:34:29 -0700 Subject: [PATCH 19/33] 15872 don't escape BANNER_MAINTENANCE (#15885) * 15872 don't escape BANNER_MAINTENANCE * 15872 don't escape BANNER_MAINTENANCE --- netbox/templates/base/layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index bb3bbc0e1..53dec6369 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -81,7 +81,7 @@ Blocks: {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} {% endif %} From 3cbade536e8925e575e017a18240b85bcafe4200 Mon Sep 17 00:00:00 2001 From: JCWasmx86 Date: Mon, 29 Apr 2024 18:46:39 +0200 Subject: [PATCH 20/33] =?UTF-8?q?Fixes=20#15812:=20Add=20Date(Time)Var=20f?= =?UTF-8?q?or=20scripts=20to=20allow=20much=20easier=20date=E2=80=A6=20(#1?= =?UTF-8?q?5821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixes #15812: Add Date(Time)Var for scripts to allow much easier date input * Extend tests for invalid data --------- Co-authored-by: Jeremy Stretch --- docs/customization/custom-scripts.md | 8 +++++ netbox/extras/scripts.py | 25 ++++++++++++++++ netbox/extras/tests/test_scripts.py | 45 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index c68bc21f1..76ca7130f 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -285,6 +285,14 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a * `min_prefix_length` - Minimum length of the mask * `max_prefix_length` - Maximum length of the mask +### DateVar + +A calendar date. Returns a `datetime.date` object. + +### DateTimeVar + +A complete date & time. Returns a `datetime.datetime` object. + ## Running Custom Scripts !!! note diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 7d86472c9..d47903f88 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -24,6 +24,7 @@ from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, from utilities.exceptions import AbortScript, AbortTransaction from utilities.forms import add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.widgets import DatePicker, DateTimePicker from .context_managers import event_tracking from .forms import ScriptForm @@ -31,6 +32,8 @@ __all__ = ( 'BaseScript', 'BooleanVar', 'ChoiceVar', + 'DateVar', + 'DateTimeVar', 'FileVar', 'IntegerVar', 'IPAddressVar', @@ -172,6 +175,28 @@ class ChoiceVar(ScriptVariable): self.field_attrs['choices'] = add_blank_choice(choices) +class DateVar(ScriptVariable): + """ + A date. + """ + form_field = forms.DateField + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_field.widget = DatePicker() + + +class DateTimeVar(ScriptVariable): + """ + A date and a time. + """ + form_field = forms.DateTimeField + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_field.widget = DateTimePicker() + + class MultiChoiceVar(ScriptVariable): """ Like ChoiceVar, but allows for the selection of multiple choices. diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 64971f1dc..bed8f0fc5 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,4 +1,5 @@ import tempfile +from datetime import date, datetime, timezone from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase @@ -322,3 +323,47 @@ class ScriptVariablesTest(TestCase): form = TestScript().as_form(data, None) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1'])) + + def test_datevar(self): + + class TestScript(Script): + + var1 = DateVar() + var2 = DateVar(required=False) + + # Test date validation + data = {'var1': 'not a date'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + input_date = date(2024, 4, 1) + data = {'var1': input_date} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], input_date) + # Validate required=False works for this Var type + self.assertEqual(form.cleaned_data['var2'], None) + + def test_datetimevar(self): + + class TestScript(Script): + + var1 = DateTimeVar() + var2 = DateTimeVar(required=False) + + # Test datetime validation + data = {'var1': 'not a datetime'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + input_datetime = datetime(2024, 4, 1, 8, 0, 0, 0, timezone.utc) + data = {'var1': input_datetime} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], input_datetime) + # Validate required=False works for this Var type + self.assertEqual(form.cleaned_data['var2'], None) From cbfed83f60f75a788c6e8d4b633c8727ab83aede Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 29 Apr 2024 10:19:57 -0700 Subject: [PATCH 21/33] 15524 round iprange utilization (#15734) --- netbox/ipam/models/ip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index ca9592d6e..e7fa90d8f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -692,7 +692,7 @@ class IPRange(PrimaryModel): ip.address.ip for ip in self.get_child_ips() ]).size - return int(float(child_count) / self.size * 100) + return min(float(child_count) / self.size * 100, 100) class IPAddress(PrimaryModel): From 0e3c35ae58cc1d128d90f26f14926657a87205cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Apr 2024 13:07:48 -0400 Subject: [PATCH 22/33] Fixes #15548: Ignore many-to-many mappings when checking dependencies of an object being deleted --- netbox/netbox/views/generic/object_views.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 6277e1c5b..031b23b8f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -339,10 +339,14 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): # Compile a mapping of models to instances dependent_objects = defaultdict(list) - for model, instance in collector.instances_with_model(): + for model, instances in collector.instances_with_model(): + # Ignore relations to auto-created models (e.g. many-to-many mappings) + if model._meta.auto_created: + continue # Omit the root object - if instance != obj: - dependent_objects[model].append(instance) + if instances == obj: + continue + dependent_objects[model].append(instances) return dict(dependent_objects) From 79b9dc20132221f09bf1cb1d7da126bfa24ba9af Mon Sep 17 00:00:00 2001 From: Julio Oliveira at Encora <149191228+Julio-Oliveira-Encora@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:15:44 -0300 Subject: [PATCH 23/33] Feature #15428 - Show all devices with configuration template attached (#15822) * Added devices instances column for config templates. * Added devices instances column for config templates. * Add counts for VMs, roles, and platforms --------- Co-authored-by: Jeremy Stretch --- netbox/extras/tables/tables.py | 26 +++++++++++++++++++++++--- netbox/extras/views.py | 9 ++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 8482c5e24..621dfd26a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -414,15 +414,35 @@ class ConfigTemplateTable(NetBoxTable): tags = columns.TagColumn( url_name='extras:configtemplate_list' ) + role_count = columns.LinkedCountColumn( + viewname='dcim:devicerole_list', + url_params={'config_template_id': 'pk'}, + verbose_name=_('Device Roles') + ) + platform_count = columns.LinkedCountColumn( + viewname='dcim:platform_list', + url_params={'config_template_id': 'pk'}, + verbose_name=_('Platforms') + ) + device_count = columns.LinkedCountColumn( + viewname='dcim:device_list', + url_params={'config_template_id': 'pk'}, + verbose_name=_('Devices') + ) + vm_count = columns.LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'config_template_id': 'pk'}, + verbose_name=_('Virtual Machines') + ) class Meta(NetBoxTable.Meta): model = ConfigTemplate fields = ( - 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', - 'tags', + 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count', + 'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags', ) default_columns = ( - 'pk', 'name', 'description', 'is_synced', + 'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count', ) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2bf5f349b..6938ccf41 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,6 +13,7 @@ from core.choices import JobStatusChoices, ManagedFileRootPathChoices from core.forms import ManagedFileForm from core.models import Job from core.tables import JobTable +from dcim.models import Device, DeviceRole, Platform from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS @@ -24,6 +25,7 @@ from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view +from virtualization.models import VirtualMachine from . import filtersets, forms, tables from .forms.reports import ReportForm from .models import * @@ -624,7 +626,12 @@ class ObjectConfigContextView(generic.ObjectView): # class ConfigTemplateListView(generic.ObjectListView): - queryset = ConfigTemplate.objects.all() + queryset = ConfigTemplate.objects.annotate( + device_count=count_related(Device, 'config_template'), + vm_count=count_related(VirtualMachine, 'config_template'), + role_count=count_related(DeviceRole, 'config_template'), + platform_count=count_related(Platform, 'config_template'), + ) filterset = filtersets.ConfigTemplateFilterSet filterset_form = forms.ConfigTemplateFilterForm table = tables.ConfigTemplateTable From 4b21cf604b351f029b398b7811e38cb103b148e0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Apr 2024 09:44:02 -0700 Subject: [PATCH 24/33] 14852 delete event-rule when delete script --- netbox/extras/api/serializers.py | 7 +++++-- netbox/extras/models/scripts.py | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8f00e11d9..43618506d 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -89,8 +89,11 @@ class EventRuleSerializer(NetBoxModelSerializer): # We need to manually instantiate the serializer for scripts if instance.action_type == EventRuleActionChoices.SCRIPT: script_name = instance.action_parameters['script_name'] - script = instance.action_object.scripts[script_name]() - return NestedScriptSerializer(script, context=context).data + if script_name in instance.action_object.scripts: + script = instance.action_object.scripts[script_name]() + return NestedScriptSerializer(script, context=context).data + else: + return None else: serializer = get_serializer_for_model( model=instance.action_object_type.model_class(), diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 93275acda..0a0d6541b 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -2,6 +2,7 @@ import inspect import logging from functools import cached_property +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -41,6 +42,13 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): """ objects = ScriptModuleManager() + event_rules = GenericRelation( + to='extras.EventRule', + content_type_field='action_object_type', + object_id_field='action_object_id', + for_concrete_model=False + ) + class Meta: proxy = True verbose_name = _('script module') From c73a974fa9ee39c08390f865286401cfbd889f21 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Apr 2024 15:26:49 -0400 Subject: [PATCH 25/33] Closes #15811: Note potential incompatibilities for remote auth headers containing underscores --- contrib/gunicorn.py | 4 ++++ docs/administration/authentication/overview.md | 5 ++++- docs/configuration/remote-authentication.md | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/contrib/gunicorn.py b/contrib/gunicorn.py index 89d6943b4..4b2b7c6b0 100644 --- a/contrib/gunicorn.py +++ b/contrib/gunicorn.py @@ -14,3 +14,7 @@ timeout = 120 # The maximum number of requests a worker can handle before being respawned max_requests = 5000 max_requests_jitter = 500 + +# Uncomment this line to accept HTTP headers containing underscores, e.g. for remote +# authentication support. See https://docs.gunicorn.org/en/stable/settings.html#header-map +# header-map = 'dangerous' diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index f81a50c0b..0c8858b2f 100644 --- a/docs/administration/authentication/overview.md +++ b/docs/administration/authentication/overview.md @@ -26,7 +26,10 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter. -Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header. +Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the user's profile during the authentication process. These headers can be customized like the `REMOTE_USER` header. + +!!! warning Verify Header Compatibility + Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`. ### Single Sign-On (SSO) diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index e7fe56a09..5f28d987f 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -85,6 +85,9 @@ Default: `'HTTP_REMOTE_USER'` When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.) +!!! warning Verify Header Compatibility + Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`. + --- ## REMOTE_AUTH_USER_EMAIL From 693c6e4da525da24e6e2f38238feea293e297ac6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Apr 2024 17:55:14 -0400 Subject: [PATCH 26/33] Changelog for #14852, #15428, #15524, #15548, #15812, #15845, #15872 --- docs/release-notes/version-3.7.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 31b99fcdc..921739e1d 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -2,12 +2,22 @@ ## v3.7.7 (FUTURE) +### Enhancements + +* [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list +* [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts + ### Bug Fixes * [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display * [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display * [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices * [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination +* [#14852](https://github.com/netbox-community/netbox/issues/14852) - Fix NoReverseMatch exception when viewing an event rule which references a deleted custom script +* [#15524](https://github.com/netbox-community/netbox/issues/15524) - Fix rounding error when reporting IP range utilization +* [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted +* [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API +* [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML --- From 11816b45e7b3f6c056b79339b5eaede069afd833 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Apr 2024 14:41:10 -0400 Subject: [PATCH 27/33] Fixes #15899: Correct the view name for the tags column on L2VPNTerminationTable --- netbox/vpn/tables/l2vpn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 91fddbd66..9a614ab98 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -74,7 +74,7 @@ class L2VPNTerminationTable(NetBoxTable): verbose_name=_('Object Site') ) tags = columns.TagColumn( - url_name='ipam:l2vpntermination_list' + url_name='vpn:l2vpntermination_list' ) class Meta(NetBoxTable.Meta): From 365bb4ba1778cdc8dc4f159b1768622fd8974053 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Apr 2024 16:13:17 -0400 Subject: [PATCH 28/33] Fixes #15896: Retain proper formatting for JSON custom field default values --- netbox/extras/models/customfields.py | 3 ++- netbox/netbox/forms/base.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index e78d1af23..c316a1c17 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,4 +1,5 @@ import decimal +import json import re from datetime import datetime, date @@ -484,7 +485,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # JSON elif self.type == CustomFieldTypeChoices.TYPE_JSON: - field = JSONField(required=required, initial=initial) + field = JSONField(required=required, initial=json.dumps(initial) if initial else '') # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 1a4155aab..84b6fbbca 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -1,3 +1,5 @@ +import json + from django import forms from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -34,7 +36,11 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, def _get_form_field(self, customfield): if self.instance.pk: form_field = customfield.to_form_field(set_initial=False) - form_field.initial = self.instance.custom_field_data.get(customfield.name, None) + initial = self.instance.custom_field_data.get(customfield.name) + if customfield.type == CustomFieldTypeChoices.TYPE_JSON: + form_field.initial = json.dumps(initial) + else: + form_field.initial = initial return form_field return customfield.to_form_field() From d256c04d9c97edb5cbbea5e15717fff26b030f1a Mon Sep 17 00:00:00 2001 From: Mattias Loverot Date: Tue, 30 Apr 2024 16:43:59 +0200 Subject: [PATCH 29/33] Added caching on /api/schema/ endpoint (closes #15894) --- netbox/netbox/urls.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 984358911..48028f248 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,6 +1,7 @@ from django.conf import settings from django.conf.urls import include from django.urls import path +from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt from django.views.static import serve from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView @@ -56,7 +57,13 @@ _patterns = [ path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path( + "api/schema/", + cache_page(timeout=86400, key_prefix=f"api_schema_{settings.VERSION}")( + SpectacularAPIView.as_view() + ), + name="schema", + ), path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'), From a2efec09beb4b0b50244ef62c4587902720bb6e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 May 2024 09:31:33 -0400 Subject: [PATCH 30/33] Fixes #15891: Ensure deterministic ordering for scripts & reports --- netbox/extras/migrations/0091_create_managedfiles.py | 2 ++ netbox/extras/models/reports.py | 1 + netbox/extras/models/scripts.py | 1 + netbox/extras/views.py | 4 ++-- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/extras/migrations/0091_create_managedfiles.py b/netbox/extras/migrations/0091_create_managedfiles.py index 79a80821f..c37ef7022 100644 --- a/netbox/extras/migrations/0091_create_managedfiles.py +++ b/netbox/extras/migrations/0091_create_managedfiles.py @@ -50,6 +50,7 @@ class Migration(migrations.Migration): ], options={ 'proxy': True, + 'ordering': ('file_root', 'file_path'), 'indexes': [], 'constraints': [], }, @@ -61,6 +62,7 @@ class Migration(migrations.Migration): ], options={ 'proxy': True, + 'ordering': ('file_root', 'file_path'), 'indexes': [], 'constraints': [], }, diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py index d28a3aec6..f3fa1e888 100644 --- a/netbox/extras/models/reports.py +++ b/netbox/extras/models/reports.py @@ -43,6 +43,7 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile): class Meta: proxy = True + ordering = ('file_root', 'file_path') verbose_name = _('report module') verbose_name_plural = _('report modules') diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 0a0d6541b..f0a0171d6 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -51,6 +51,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): class Meta: proxy = True + ordering = ('file_root', 'file_path') verbose_name = _('script module') verbose_name_plural = _('script modules') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6938ccf41..2e3bd54f0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1042,7 +1042,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request): - report_modules = ReportModule.objects.restrict(request.user) + report_modules = ReportModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file') return render(request, 'extras/report_list.html', { 'model': ReportModule, @@ -1217,7 +1217,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request): - script_modules = ScriptModule.objects.restrict(request.user) + script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file') return render(request, 'extras/script_list.html', { 'model': ScriptModule, From c08784da462e0aa54ce9c1b5fb356cc59c85c73e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 1 May 2024 13:24:50 -0500 Subject: [PATCH 31/33] Fixes #11460 - Fix unterminated cable exception when editing cable (#15813) * Fix cable edit form with single unterminated cable * Minor tweaks * Instead of skipping HTMX, override the template & move form template to an "htmx" template * Use HTMXSelect widget for A/B type selection * Infer A/B termination types from POST data * Fix saving cable which results in resetting of the termination type fields * Condense view logic --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/connections.py | 21 +++-- netbox/dcim/forms/model_forms.py | 26 +++++- netbox/dcim/views.py | 37 ++++----- netbox/netbox/views/generic/object_views.py | 3 +- netbox/templates/dcim/cable_edit.html | 87 +------------------ netbox/templates/dcim/htmx/cable_edit.html | 92 +++++++++++++++++++++ 6 files changed, 148 insertions(+), 118 deletions(-) create mode 100644 netbox/templates/dcim/htmx/cable_edit.html diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 854c5ebed..90a3d6ce0 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from circuits.models import Circuit, CircuitTermination @@ -82,14 +83,22 @@ def get_cable_form(a_type, b_type): class _CableForm(CableForm, metaclass=FormMetaclass): - def __init__(self, *args, **kwargs): + def __init__(self, *args, initial=None, **kwargs): + + initial = initial or {} + if a_type: + ct = ContentType.objects.get_for_model(a_type) + initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}' + if b_type: + ct = ContentType.objects.get_for_model(b_type) + initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}' # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict() for field_name in ('a_terminations', 'b_terminations'): - if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list: - kwargs['initial'][field_name] = [kwargs['initial'][field_name]] + if field_name in initial and type(initial[field_name]) is not list: + initial[field_name] = [initial[field_name]] - super().__init__(*args, **kwargs) + super().__init__(*args, initial=initial, **kwargs) if self.instance and self.instance.pk: # Initialize A/B terminations when modifying an existing Cable instance @@ -100,7 +109,7 @@ def get_cable_form(a_type, b_type): super().clean() # Set the A/B terminations on the Cable instance - self.instance.a_terminations = self.cleaned_data['a_terminations'] - self.instance.b_terminations = self.cleaned_data['b_terminations'] + self.instance.a_terminations = self.cleaned_data.get('a_terminations', []) + self.instance.b_terminations = self.cleaned_data.get('b_terminations', []) return _CableForm diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index cee8fcfba..cc7100e3c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -13,8 +13,7 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms.fields import ( - CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, - NumericArrayField, SlugField, + CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster @@ -616,14 +615,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): self.fields['adopt_components'].disabled = True +def get_termination_type_choices(): + return add_blank_choice([ + (f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title()) + for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ]) + + class CableForm(TenancyForm, NetBoxModelForm): + a_terminations_type = forms.ChoiceField( + choices=get_termination_type_choices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) + b_terminations_type = forms.ChoiceField( + choices=get_termination_type_choices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) comments = CommentField() class Meta: model = Cable fields = [ - 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', - 'comments', 'tags', + 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'description', 'comments', 'tags', ] error_messages = { 'length': { diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ce4bb5750..805e9d881 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3183,34 +3183,29 @@ class CableView(generic.ObjectView): class CableEditView(generic.ObjectEditView): queryset = Cable.objects.all() template_name = 'dcim/cable_edit.html' + htmx_template_name = 'dcim/htmx/cable_edit.html' - def dispatch(self, request, *args, **kwargs): - - # If creating a new Cable, initialize the form class using URL query params - if 'pk' not in kwargs: - self.form = forms.get_cable_form( - a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')), - b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type')) - ) - - return super().dispatch(request, *args, **kwargs) - - def get_object(self, **kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): """ - Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView + Hack into alter_object() to set the form class when editing an existing Cable, since ObjectEditView doesn't currently provide a hook for dynamic class resolution. """ - obj = super().get_object(**kwargs) + a_terminations_type = CABLE_TERMINATION_TYPES.get( + request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type') + ) + b_terminations_type = CABLE_TERMINATION_TYPES.get( + request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type') + ) if obj.pk: - # TODO: Optimize this logic - termination_a = obj.terminations.filter(cable_end='A').first() - a_type = termination_a.termination._meta.model if termination_a else None - termination_b = obj.terminations.filter(cable_end='B').first() - b_type = termination_b.termination._meta.model if termination_b else None - self.form = forms.get_cable_form(a_type, b_type) + if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()): + a_terminations_type = termination_a.termination._meta.model + if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()): + b_terminations_type = termination_b.termination._meta.model - return obj + self.form = forms.get_cable_form(a_terminations_type, b_terminations_type) + + return super().alter_object(obj, request, url_args, url_kwargs) def get_extra_addanother_params(self, request): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 031b23b8f..57ea45274 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -167,6 +167,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ template_name = 'generic/object_edit.html' form = None + htmx_template_name = 'htmx/form.html' def dispatch(self, request, *args, **kwargs): # Determine required permission based on whether we are editing an existing object @@ -228,7 +229,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): # If this is an HTMX request, return only the rendered form HTML if is_htmx(request): - return render(request, 'htmx/form.html', { + return render(request, self.htmx_template_name, { 'form': form, }) diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 14d763dc3..fbe877d87 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,90 +1,5 @@ {% extends 'generic/object_edit.html' %} -{% load static %} -{% load helpers %} -{% load form_helpers %} -{% load i18n %} {% block form %} - - {# A side termination #} -
-
-
{% trans "A Side" %}
-
- {% if 'termination_a_device' in form.fields %} - {% render_field form.termination_a_device %} - {% endif %} - {% if 'termination_a_powerpanel' in form.fields %} - {% render_field form.termination_a_powerpanel %} - {% endif %} - {% if 'termination_a_circuit' in form.fields %} - {% render_field form.termination_a_circuit %} - {% endif %} - {% render_field form.a_terminations %} -
- - {# B side termination #} -
-
-
{% trans "B Side" %}
-
- {% if 'termination_b_device' in form.fields %} - {% render_field form.termination_b_device %} - {% endif %} - {% if 'termination_b_powerpanel' in form.fields %} - {% render_field form.termination_b_powerpanel %} - {% endif %} - {% if 'termination_b_circuit' in form.fields %} - {% render_field form.termination_b_circuit %} - {% endif %} - {% render_field form.b_terminations %} -
- - {# Cable attributes #} -
-
-
{% trans "Cable" %}
-
- {% render_field form.status %} - {% render_field form.type %} - {% render_field form.label %} - {% render_field form.description %} - {% render_field form.color %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
- {% render_field form.tags %} -
- -
-
-
{% trans "Tenancy" %}
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} - - {% if form.comments %} -
-
{% trans "Comments" %}
- {% render_field form.comments %} -
- {% endif %} - + {% include 'dcim/htmx/cable_edit.html' %} {% endblock %} diff --git a/netbox/templates/dcim/htmx/cable_edit.html b/netbox/templates/dcim/htmx/cable_edit.html new file mode 100644 index 000000000..5f22fe372 --- /dev/null +++ b/netbox/templates/dcim/htmx/cable_edit.html @@ -0,0 +1,92 @@ +{% load static %} +{% load helpers %} +{% load form_helpers %} +{% load i18n %} + + +{# A side termination #} +
+
+
{% trans "A Side" %}
+
+ {% render_field form.a_terminations_type %} + {% if 'termination_a_device' in form.fields %} + {% render_field form.termination_a_device %} + {% endif %} + {% if 'termination_a_powerpanel' in form.fields %} + {% render_field form.termination_a_powerpanel %} + {% endif %} + {% if 'termination_a_circuit' in form.fields %} + {% render_field form.termination_a_circuit %} + {% endif %} + {% if 'a_terminations' in form.fields %} + {% render_field form.a_terminations %} + {% endif %} +
+ +{# B side termination #} +
+
+
{% trans "B Side" %}
+
+ {% render_field form.b_terminations_type %} + {% if 'termination_b_device' in form.fields %} + {% render_field form.termination_b_device %} + {% endif %} + {% if 'termination_b_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} + {% if 'b_terminations' in form.fields %} + {% render_field form.b_terminations %} + {% endif %} +
+ +{# Cable attributes #} +
+
+
{% trans "Cable" %}
+
+ {% render_field form.status %} + {% render_field form.type %} + {% render_field form.label %} + {% render_field form.description %} + {% render_field form.color %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+ {% render_field form.tags %} +
+ +
+
+
{% trans "Tenancy" %}
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+ +{% if form.custom_fields %} +
+
+
{% trans "Custom Fields" %}
+
+ {% render_custom_fields form %} +
+{% endif %} + +{% if form.comments %} +
+
{% trans "Comments" %}
+ {% render_field form.comments %} +
+{% endif %} \ No newline at end of file From 340f9f4fa828c67afba58d41cf9a0b30e27bd9dc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 May 2024 14:52:15 -0400 Subject: [PATCH 32/33] Changelog for #11460, #15891, #15894, #15896, #15899; add warning for #15811 --- docs/release-notes/version-3.7.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 921739e1d..ab93bab35 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -6,9 +6,11 @@ * [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list * [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts +* [#15894](https://github.com/netbox-community/netbox/issues/15894) - Cache the generated API schema definition for shorter loading times ### Bug Fixes +* [#11460](https://github.com/netbox-community/netbox/issues/11460) - Fix AttributeError exception when editing a cable with only one end terminated * [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display * [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display * [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices @@ -18,11 +20,17 @@ * [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted * [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API * [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML +* [#15891](https://github.com/netbox-community/netbox/issues/15891) - Ensure deterministic ordering for scripts & reports +* [#15896](https://github.com/netbox-community/netbox/issues/15896) - Fix retention of default value when editing a custom JSON field +* [#15899](https://github.com/netbox-community/netbox/issues/15899) - Fix exception when enabling the tags column on the L2VPN terminations table --- ## v3.7.6 (2024-04-22) +!!! warning + If remote authentication is in use with Gunicorn v22.0 or later, it may be necessary to configure Gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to preserve authentication headers. + ### Enhancements * [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form From 335a8d6449e5ff47616fdd8eb7f8302aaa7c9802 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 May 2024 15:08:08 -0400 Subject: [PATCH 33/33] Release v3.7.7 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.7.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 10 +++++----- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 8fc9bc205..3679e5db9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -26,7 +26,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.7.6 + placeholder: v3.7.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3e7372484..0cf522960 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.7.6 + placeholder: v3.7.7 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index ab93bab35..337526c14 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,6 +1,6 @@ # NetBox v3.7 -## v3.7.7 (FUTURE) +## v3.7.7 (2024-05-01) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 94fb9f891..031c984f1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig # Environment setup # -VERSION = '3.7.7-dev' +VERSION = '3.7.7' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 78b423692..0cf22c7d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,21 +15,21 @@ django-tables2==2.7.0 django-timezone-field==6.1.0 djangorestframework==3.14.0 drf-spectacular==0.27.2 -drf-spectacular-sidecar==2024.4.1 +drf-spectacular-sidecar==2024.5.1 feedparser==6.0.11 graphene-django==3.0.0 gunicorn==22.0.0 Jinja2==3.1.3 Markdown==3.6 -mkdocs-material==9.5.18 -mkdocstrings[python-legacy]==0.24.3 +mkdocs-material==9.5.20 +mkdocstrings[python-legacy]==0.25.0 netaddr==1.2.1 Pillow==10.3.0 psycopg[binary,pool]==3.1.18 PyYAML==6.0.1 requests==2.31.0 -social-auth-app-django==5.4.0 -social-auth-core[openidconnect]==4.5.3 +social-auth-app-django==5.4.1 +social-auth-core==4.5.4 svgwrite==1.4.3 tablib==3.6.1 tzdata==2024.1