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/development/user-preferences.md b/docs/development/user-preferences.md index ceb5321a9..deb469bfb 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -11,4 +11,3 @@ The `users.UserConfig` model holds individual preferences for each user in the f | pagination.placement | Where to display the paginator controls relative to the table | | tables.${table}.columns | The ordered list of columns to display when viewing the table | | tables.${table}.ordering | A list of column names by which the table should be ordered | -| ui.colormode | Light or dark mode in the user interface | 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/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index 8add21fba..9e62679c4 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -93,25 +93,14 @@ The legacy admin user interface is now disabled by default, and the few remainin * [#15754](https://github.com/netbox-community/netbox/issues/15754) - Remove `is_staff` restriction on admin menu items * [#15764](https://github.com/netbox-community/netbox/issues/15764) - Increase maximum value of Device `vc_position` field -### Bug Fixes (from Beta1) +### Bug Fixes (from Beta2) -* [#15580](https://github.com/netbox-community/netbox/issues/15580) - Fix rendering of modals with HTMX navigation enabled -* [#15605](https://github.com/netbox-community/netbox/issues/15605) - Fix `ProgrammingError` exception when applying migrations to older databases -* [#15613](https://github.com/netbox-community/netbox/issues/15613) - Restore the login button/user menu on mobile view -* [#15616](https://github.com/netbox-community/netbox/issues/15616) - Fix button style for invalid custom links -* [#15617](https://github.com/netbox-community/netbox/issues/15617) - Fix rack elevation styling under dark mode -* [#15619](https://github.com/netbox-community/netbox/issues/15619) - Enforce a minimum width for progress bars -* [#15636](https://github.com/netbox-community/netbox/issues/15636) - Fix filtering of attached images when viewing an object in the UI -* [#15637](https://github.com/netbox-community/netbox/issues/15637) - Correct nonfunctional links within embedded tables when HTMX enabled -* [#15638](https://github.com/netbox-community/netbox/issues/15638) - Correct parameter used to retrieve saved filters for a model -* [#15641](https://github.com/netbox-community/netbox/issues/15641) - Fix adding/removing filters on the advanced object selector widget -* [#15652](https://github.com/netbox-community/netbox/issues/15652) - Fix the display of error messages after attempting to delete an object -* [#15671](https://github.com/netbox-community/netbox/issues/15671) - Fix `ValueError` exception when uploading a custom script -* [#15695](https://github.com/netbox-community/netbox/issues/15695) - Fix `ForeignKeyViolation` exception when applying migration `users.0006_custom_group_model` on older databases -* [#15698](https://github.com/netbox-community/netbox/issues/15698) - Fix ProgrammingError exception when applying the `users.0008_flip_objectpermission_assignments` migration to older databases -* [#15760](https://github.com/netbox-community/netbox/issues/15760) - Permit breaking of long words for wrap within object attribute tables -* [#15778](https://github.com/netbox-community/netbox/issues/15778) - Fix bulk edit/delete functionality when HTMX is enabled -* [#15789](https://github.com/netbox-community/netbox/issues/15789) - Avoid AttributeError exception when attempting to view script results before job execution has completed +* [#15809](https://github.com/netbox-community/netbox/issues/15809) - Fix GraphQL union support for nullable fields +* [#15815](https://github.com/netbox-community/netbox/issues/15815) - Convert dashboard widgets referencing old user/group models +* [#15826](https://github.com/netbox-community/netbox/issues/15826) - Update `EXEMPT_EXCLUDE_MODELS` to reference new user & group models +* [#15838](https://github.com/netbox-community/netbox/issues/15838) - Fix AttributeError exception when rendering custom date fields +* [#15853](https://github.com/netbox-community/netbox/issues/15853) - Correct background color for cable trace SVG images in dark mode +* [#15855](https://github.com/netbox-community/netbox/issues/15855) - Fix AttributeError exception when creating an event rule tied to a custom script ### Other Changes 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/api/serializers_/events.py b/netbox/extras/api/serializers_/events.py index 4285b12e6..469da3e8c 100644 --- a/netbox/extras/api/serializers_/events.py +++ b/netbox/extras/api/serializers_/events.py @@ -47,8 +47,7 @@ class EventRuleSerializer(NetBoxModelSerializer): # We need to manually instantiate the serializer for scripts if instance.action_type == EventRuleActionChoices.SCRIPT: script = instance.action_object - instance = script.python_class() if script.python_class else None - return ScriptSerializer(instance, nested=True, context=context).data + return ScriptSerializer(script, nested=True, context=context).data else: serializer = get_serializer_for_model(instance.action_object_type.model_class()) return serializer(instance.action_object, nested=True, context=context).data diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 23f082ce2..a3d7f05a3 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -1,3 +1,4 @@ +import logging import uuid from functools import cached_property from hashlib import sha256 @@ -32,6 +33,8 @@ __all__ = ( 'WidgetConfigForm', ) +logger = logging.getLogger('netbox.data_backends') + def get_object_type_choices(): return [ @@ -54,8 +57,15 @@ def get_models_from_content_types(content_types): models = [] for content_type_id in content_types: app_label, model_name = content_type_id.split('.') - content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) - models.append(content_type.model_class()) + try: + content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) + if content_type.model_class(): + models.append(content_type.model_class()) + else: + logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}") + except ObjectType.DoesNotExist: + logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}") + return models diff --git a/netbox/extras/events.py b/netbox/extras/events.py index a33ac213c..34d2ec159 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -118,7 +118,7 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna # Enqueue a Job to record the script's execution Job.enqueue( "extras.scripts.run_script", - instance=script.module, + instance=event_rule.action_object, name=script.name, user=user, data=data 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/migrations/0115_convert_dashboard_widgets.py b/netbox/extras/migrations/0115_convert_dashboard_widgets.py new file mode 100644 index 000000000..c85c83ecf --- /dev/null +++ b/netbox/extras/migrations/0115_convert_dashboard_widgets.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.4 on 2024-04-24 20:09 + +from django.db import migrations + + +def update_dashboard_widgets(apps, schema_editor): + Dashboard = apps.get_model('extras', 'Dashboard') + + for dashboard in Dashboard.objects.all(): + for key, widget in dashboard.config.items(): + if models := widget['config'].get('models'): + models = list(map(lambda x: x.replace('users.netboxgroup', 'users.group'), models)) + models = list(map(lambda x: x.replace('users.netboxuser', 'users.user'), models)) + dashboard.config[key]['config']['models'] = models + dashboard.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0114_customfield_add_comments'), + ] + + operations = [ + migrations.RunPython( + code=update_dashboard_widgets, + reverse_code=migrations.RunPython.noop + ), + ] 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/preferences.py b/netbox/netbox/preferences.py index aa37bffae..d560ef1dd 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -15,15 +15,6 @@ def get_page_lengths(): PREFERENCES = { # User interface - 'ui.colormode': UserPreference( - label=_('Color mode'), - choices=( - ('light', _('Light')), - ('dark', _('Dark')), - ), - default='light', - description=_('Preferred default UI theme') - ), 'ui.htmx_navigation': UserPreference( label=_('HTMX Navigation'), choices=( 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/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 54f01c3f7..3b9fe7504 100644 Binary files a/netbox/project-static/dist/cable_trace.css and b/netbox/project-static/dist/cable_trace.css differ 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 5f0c8029c..c880e23e9 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 2e165eee6..adc770528 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/js/setmode.js b/netbox/project-static/js/setmode.js index ff1c5366b..9418a9f39 100644 --- a/netbox/project-static/js/setmode.js +++ b/netbox/project-static/js/setmode.js @@ -1,13 +1,11 @@ /** * Set the color mode on the `` element and in local storage. * - * @param mode {"dark" | "light"} NetBox Color Mode. - * @param inferred {boolean} Value is inferred from browser/system preference. + * @param mode {"dark" | "light"} UI color mode. */ -function setMode(mode, inferred) { +function setMode(mode) { document.documentElement.setAttribute("data-bs-theme", mode); localStorage.setItem("netbox-color-mode", mode); - localStorage.setItem("netbox-color-mode-inferred", inferred); } /** @@ -15,59 +13,29 @@ function setMode(mode, inferred) { */ function initMode() { try { - // Browser prefers dark color scheme. - var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - // Browser prefers light color scheme. - var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches; - // Client NetBox color-mode override. + // Determine the configured color mode, if any var clientMode = localStorage.getItem("netbox-color-mode"); - // NetBox server-rendered value. - var serverMode = document.documentElement.getAttribute("data-netbox-color-mode"); - // Color mode is inferred from browser/system preference and not deterministically set by - // the client or server. - var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred")); + // Detect browser preference, if set + var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches; - if (inferred === true && (serverMode === "light" || serverMode === "dark")) { - // The color mode was previously inferred from browser/system preference, but - // the server now has a value, so we should use the server's value. - return setMode(serverMode, false); - } - if (clientMode === null && (serverMode === "light" || serverMode === "dark")) { - // If the client mode is not set but the server mode is, use the server mode. - return setMode(serverMode, false); - } - if (clientMode !== null && serverMode === "unset") { - // The color mode has been set, deterministically or otherwise, and the server - // has no preference or has not been set. Use the client mode, but allow it to - /// be overridden by the server if/when a server value exists. - return setMode(clientMode, true); - } - if ( - clientMode !== null && - (serverMode === "light" || serverMode === "dark") && - clientMode !== serverMode - ) { - // If the client mode is set and is different than the server mode (which is also set), - // use the client mode over the server mode, as it should be more recent. + // Use the selected color mode, if any + if (clientMode !== null) { return setMode(clientMode, false); } - if (clientMode === serverMode) { - // If the client and server modes match, use that value. - return setMode(clientMode, false); - } - if (preferDark && serverMode === "unset") { - // If the server mode is not set but the browser prefers dark mode, use dark mode, but - // allow it to be overridden by an explicit preference. + + // Fall back to the mode preferred by the browser, if specified + if (preferDark) { return setMode("dark", true); } - if (preferLight && serverMode === "unset") { - // If the server mode is not set but the browser prefers light mode, use light mode, - // but allow it to be overridden by an explicit preference. + else if (preferLight) { return setMode("light", true); } } catch (error) { // In the event of an error, log it to the console and set the mode to light mode. console.error(error); } + + // Default to light mode return setMode("light", true); } 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/src/colorMode.ts b/netbox/project-static/src/colorMode.ts index 894a1e8a5..453617740 100644 --- a/netbox/project-static/src/colorMode.ts +++ b/netbox/project-static/src/colorMode.ts @@ -65,9 +65,8 @@ function handleColorModeToggle(): void { function defaultColorMode(): void { // Get the current color mode value from local storage. const currentValue = localStorage.getItem(COLOR_MODE_KEY) as Nullable; - const serverValue = document.documentElement.getAttribute(`data-${COLOR_MODE_KEY}`); - if (isTruthy(serverValue) && isTruthy(currentValue)) { + if (isTruthy(currentValue)) { return setColorMode(currentValue); } @@ -81,7 +80,7 @@ function defaultColorMode(): void { } } - if (isTruthy(currentValue) && !isTruthy(serverValue) && isColorMode(currentValue)) { + if (isTruthy(currentValue) && isColorMode(currentValue)) { return setColorMode(currentValue); } 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/project-static/styles/svg/cable_trace.scss b/netbox/project-static/styles/svg/cable_trace.scss index b7b09c219..b146c88aa 100644 --- a/netbox/project-static/styles/svg/cable_trace.scss +++ b/netbox/project-static/styles/svg/cable_trace.scss @@ -8,6 +8,7 @@ :root { // Light mode values + --nbx-trace-bg: var(--tblr-bg-surface-secondary); --nbx-trace-color: #{$black}; --nbx-trace-node-bg: #{$gray-200}; --nbx-trace-termination-bg: #{$gray-100}; @@ -16,6 +17,7 @@ &[data-bs-theme='dark'] { // Dark mode values + --nbx-trace-bg: rgb(27, 41, 58); --nbx-trace-color: #{$white}; --nbx-trace-node-bg: #{$gray-900}; --nbx-trace-termination-bg: #{$gray-800}; @@ -44,6 +46,8 @@ text { } svg { + background-color: var(--nbx-trace-bg); + /* Boxes */ rect { fill: var(--nbx-trace-node-bg); @@ -79,4 +83,3 @@ svg { stroke-dasharray: 5px; } } - diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index acaff4295..f7fa3fa50 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -8,9 +8,6 @@ lang="en" data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-base-path="{{ settings.BASE_PATH }}" - {% with preferences|get_key:'ui.colormode' as color_mode %} - data-netbox-color-mode="{{ color_mode|default:"unset" }}" - {% endwith %} > diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 9365df43b..d53591cb4 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -47,7 +47,7 @@ Blocks: {# Top menu #} -