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/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 3a3b9efc2..a6c3a3159 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 diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index bdc3f9104..21ae20f05 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -371,6 +371,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/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 64fdc7dfe..337526c14 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,11 +1,36 @@ # NetBox v3.7 -## v3.7.7 (FUTURE) +## v3.7.7 (2024-05-01) + +### 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 +* [#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 +* [#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 +* [#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 diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 67ef360fe..b57f4ad2c 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 @@ -88,14 +89,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 @@ -106,7 +115,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 81c1b17c7..d5cc0e856 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -628,14 +628,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/svg/cables.py b/netbox/dcim/svg/cables.py index aaa9e24ed..f07b21747 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.html 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,89 @@ 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] + + # 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 +344,99 @@ 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 isinstance(cable, Cable): + labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()] + if cable.type: + 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' - # 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] + 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"{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 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] - # 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=isinstance(cable, 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( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 98dcfcb3c..800b55bf1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -51,34 +51,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. - """ - if record.enabled: - return 'enabled' - else: - return 'disabled' - - -def get_interface_connected_attribute(record): - """ - Get interface disconnected state as string to attach to DOM element. - """ - if record.mark_connected or record.cable: - return 'connected' - else: - return 'disconnected' - - # # Device roles # @@ -706,11 +678,12 @@ 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-type': lambda record: record.type, - 'data-connected': get_interface_connected_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 } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 120bbcb59..48a8656e8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3177,34 +3177,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/extras/migrations/0087_squashed_0098.py b/netbox/extras/migrations/0087_squashed_0098.py index 57ad3af00..55f276ecd 100644 --- a/netbox/extras/migrations/0087_squashed_0098.py +++ b/netbox/extras/migrations/0087_squashed_0098.py @@ -68,6 +68,7 @@ class Migration(migrations.Migration): ], options={ 'proxy': True, + 'ordering': ('file_root', 'file_path'), 'indexes': [], 'constraints': [], }, @@ -79,6 +80,7 @@ class Migration(migrations.Migration): ], options={ 'proxy': True, + 'ordering': ('file_root', 'file_path'), 'indexes': [], 'constraints': [], }, diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 676c6c66a..974affb2e 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 @@ -488,7 +489,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/extras/models/scripts.py b/netbox/extras/models/scripts.py index b0344443c..9118ab2ca 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -97,8 +97,16 @@ 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 + ordering = ('file_root', 'file_path') verbose_name = _('script module') verbose_name_plural = _('script modules') diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 71faa47e2..0e74c3f0d 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 from .utils import is_report @@ -33,6 +34,8 @@ __all__ = ( 'BaseScript', 'BooleanVar', 'ChoiceVar', + 'DateVar', + 'DateTimeVar', 'FileVar', 'IntegerVar', 'IPAddressVar', @@ -174,6 +177,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/tables/tables.py b/netbox/extras/tables/tables.py index c448c2a31..8da3ea93a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -419,15 +419,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/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) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index be3937512..ff147cc31 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,6 +13,7 @@ from core.choices import 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 @@ -28,6 +29,7 @@ from utilities.request import copy_safe_request from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view +from virtualization.models import VirtualMachine from . import filtersets, forms, tables from .models import * from .scripts import run_script @@ -627,7 +629,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 @@ -1035,7 +1042,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request): - script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs') + script_modules = ScriptModule.objects.restrict(request.user).prefetch_related( + 'data_source', 'data_file', 'jobs' + ) return render(request, 'extras/script_list.html', { 'model': ScriptModule, diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 422c5ba37..fb1c123c3 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): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 21cdf891d..d59f61ef9 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 @@ -35,7 +37,11 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms 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() diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 1ce929513..a5e5e36f4 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.static import serve from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView @@ -10,7 +11,6 @@ from netbox.graphql.schema import schema from netbox.graphql.views import NetBoxGraphQLView from netbox.plugins.urls import plugin_patterns, plugin_api_patterns from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx -from strawberry.django.views import GraphQLView _patterns = [ @@ -55,7 +55,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'), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index d9ac2e9ff..65ab490e0 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 htmx_partial(request): - return render(request, 'htmx/form.html', { + return render(request, self.htmx_template_name, { 'form': form, }) @@ -339,10 +340,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) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 84d1600e3..42f40aeb8 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 9048a3286..df5874b48 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 1a73196a1..8d39b302d 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index af0d70c07..5a8a9e833 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 9d18c575e..c44bc6481 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ 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/project-static/styles/custom/_interfaces.scss b/netbox/project-static/styles/custom/_interfaces.scss new file mode 100644 index 000000000..2c363e7ff --- /dev/null +++ b/netbox/project-static/styles/custom/_interfaces.scss @@ -0,0 +1,29 @@ +@use 'sass:map'; + +// Interface row coloring +tr[data-cable-status=connected] { + background-color: rgba(map.get($theme-colors, "green"), 0.15); +} +tr[data-cable-status=planned] { + background-color: rgba(map.get($theme-colors, "blue"), 0.15); +} +tr[data-cable-status=decommissioning] { + background-color: rgba(map.get($theme-colors, "yellow"), 0.15); +} +tr[data-mark-connected=true] { + background-color: rgba(map.get($theme-colors, "success"), 0.15); +} +tr[data-virtual=true] { + background-color: rgba(map.get($theme-colors, "primary"), 0.15); +} +tr[data-enabled=disabled] { + background-color: rgba(map.get($theme-colors, "danger"), 0.15); +} + +// Only show the correct button depending on the cable status +tr[data-cable-status=connected] button.mark-installed { + display: none; +} +tr:not([data-cable-status=connected]) button.mark-planned { + display: none; +} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index aa7150be7..b04b85fc9 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -20,5 +20,6 @@ // Custom styling @import 'custom/code'; +@import 'custom/interfaces'; @import 'custom/markdown'; @import 'custom/misc'; diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index a9df01cd5..d53591cb4 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -99,7 +99,7 @@ Blocks: {% endif %} {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} - {% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|escape %} + {% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|safe %} {% endif %} {# /Alerts #} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index b5c1f8752..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..acd7baf0f --- /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 %} 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 %} 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 "" 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): diff --git a/requirements.txt b/requirements.txt index 473a63202..d9d47a6bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,20 +15,20 @@ django-tables2==2.7.0 django-timezone-field==6.1.0 djangorestframework==3.15.1 drf-spectacular==0.27.2 -drf-spectacular-sidecar==2024.4.1 +drf-spectacular-sidecar==2024.5.1 feedparser==6.0.11 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 nh3==0.2.17 Pillow==10.3.0 psycopg[c,pool]==3.1.18 PyYAML==6.0.1 requests==2.31.0 -social-auth-app-django==5.4.0 +social-auth-app-django==5.4.1 social-auth-core==4.5.4 strawberry-graphql==0.227.2 strawberry-graphql-django==0.34.0