diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 3b502cab2..7fd510841 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -55,3 +55,7 @@ The link will only appear when viewing a device with a manufacturer name of "Cis ## Link Groups Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. + +## Table Columns + +Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index a09b43400..d50404261 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,10 +2,23 @@ ## v3.1.3 (FUTURE) +### Enhancements + +* [#6782](https://github.com/netbox-community/netbox/issues/6782) - Enable the inclusion of custom links in tables +* [#8100](https://github.com/netbox-community/netbox/issues/8100) - Add "other" choice for FHRP group protocol + ### Bug Fixes +* [#7246](https://github.com/netbox-community/netbox/issues/7246) - Don't attempt to URL-decode NAPALM response payloads +* [#7887](https://github.com/netbox-community/netbox/issues/7887) - Forward `HTTP_X_FORWARDED_FOR` to custom scripts * [#7962](https://github.com/netbox-community/netbox/issues/7962) - Fix user menu under report/script result view +* [#7972](https://github.com/netbox-community/netbox/issues/7972) - Standardize name of `RemoteUserBackend` logger +* [#8097](https://github.com/netbox-community/netbox/issues/8097) - Fix styling of Markdown tables +* [#8127](https://github.com/netbox-community/netbox/issues/8127) - Fix disassociation of interface under IP address edit view * [#8131](https://github.com/netbox-community/netbox/issues/8131) - Restore annotation of available IPs under prefix IPs view +* [#8134](https://github.com/netbox-community/netbox/issues/8134) - Fix bulk editing of objects within dynamic tables +* [#8139](https://github.com/netbox-community/netbox/issues/8139) - Fix rendering of table configuration form under VM interfaces view +* [#8140](https://github.com/netbox-community/netbox/issues/8140) - Restore missing fields on wireless LAN & link REST API serializers --- diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9d1be93d5..8838eda2c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,14 +15,14 @@ from circuits.models import Circuit from dcim import filtersets from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet -from ipam.models import Prefix, VLAN, ASN +from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from netbox.api.views import ModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model -from utilities.utils import count_related, decode_dict +from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -516,7 +516,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): response[method] = {'error': 'Only get_* NAPALM methods are supported'} continue try: - response[method] = decode_dict(getattr(d, method)()) + response[method] = getattr(d, method)() except NotImplementedError: response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} except Exception as e: diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index c20117b91..5471f4d67 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:customlink', args=[self.pk]) + def render(self, context): + """ + Render the CustomLink given the provided context, and return the text, link, and link_target. + + :param context: The context passed to Jinja2 + """ + text = render_jinja2(self.link_text, context) + if not text: + return {} + link = render_jinja2(self.link_url, context) + link_target = ' target="_blank"' if self.new_window else '' + + return { + 'text': text, + 'link': link, + 'link_target': link_target, + } + @extras_features('webhooks', 'export_templates') class ExportTemplate(ChangeLoggedModel): diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index fec5cf65a..32ec966b3 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -62,16 +62,14 @@ def custom_links(context, obj): # Add non-grouped links else: try: - text_rendered = render_jinja2(cl.link_text, link_context) - if text_rendered: - link_rendered = render_jinja2(cl.link_url, link_context) - link_target = ' target="_blank"' if cl.new_window else '' + rendered = cl.render(link_context) + if rendered: template_code += LINK_BUTTON.format( - link_rendered, link_target, cl.button_class, text_rendered + rendered['link'], rendered['link_target'], cl.button_class, rendered['text'] ) except Exception as e: - template_code += '' \ - ' {}\n'.format(e, cl.name) + template_code += f'' \ + f' {cl.name}\n' # Add grouped links to template for group, links in group_names.items(): @@ -80,17 +78,15 @@ def custom_links(context, obj): for cl in links: try: - text_rendered = render_jinja2(cl.link_text, link_context) - if text_rendered: - link_target = ' target="_blank"' if cl.new_window else '' - link_rendered = render_jinja2(cl.link_url, link_context) + rendered = cl.render(link_context) + if rendered: links_rendered.append( - GROUP_LINK.format(link_rendered, link_target, text_rendered) + GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text']) ) except Exception as e: links_rendered.append( - '
  • ' - ' {}
  • '.format(e, cl.name) + f'
  • ' + f' {cl.name}
  • ' ) if links_rendered: diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 693ee6689..849f6a6bc 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -106,6 +106,7 @@ class FHRPGroupProtocolChoices(ChoiceSet): PROTOCOL_HSRP = 'hsrp' PROTOCOL_GLBP = 'glbp' PROTOCOL_CARP = 'carp' + PROTOCOL_OTHER = 'other' CHOICES = ( (PROTOCOL_VRRP2, 'VRRPv2'), @@ -113,6 +114,7 @@ class FHRPGroupProtocolChoices(ChoiceSet): (PROTOCOL_HSRP, 'HSRP'), (PROTOCOL_GLBP, 'GLBP'), (PROTOCOL_CARP, 'CARP'), + (PROTOCOL_OTHER, 'Other'), ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 319d8671e..c5e3146e9 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm): }) elif selected_objects: self.instance.assigned_object = self.cleaned_data[selected_objects[0]] + else: + self.instance.assigned_object = None # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a67ec451d..acb04ce34 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend): return settings.REMOTE_AUTH_AUTO_CREATE_USER def configure_groups(self, user, remote_groups): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') # Assign default groups to the user group_list = [] @@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend): Return None if ``create_unknown_user`` is ``False`` and a ``User`` object with the given username is not found in the database. """ - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') logger.debug( f"trying to authenticate {remote_user} with groups {remote_groups}") if not remote_user: @@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend): return None def _is_superuser(self, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS logger.debug(f"Superuser Groups: {superuser_groups}") superusers = settings.REMOTE_AUTH_SUPERUSERS @@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend): return bool(result) def _is_staff(self, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS logger.debug(f"Superuser Groups: {staff_groups}") staff_users = settings.REMOTE_AUTH_STAFF_USERS @@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend): return bool(result) def configure_user(self, request, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED: # Assign default groups to the user group_list = [] diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 25017505e..e711685bf 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 07ad0dba2..10c15397d 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-print.css b/netbox/project-static/dist/netbox-print.css index a09f49222..4562597d8 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 740fbe7e7..d9b437531 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 116aad5e6..7586aad12 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/forms/elements.ts b/netbox/project-static/src/forms/elements.ts index 5cb17f5c7..9e2ae67c4 100644 --- a/netbox/project-static/src/forms/elements.ts +++ b/netbox/project-static/src/forms/elements.ts @@ -35,11 +35,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { for (const element of form.querySelectorAll('*[name]')) { if (!element.validity.valid) { invalids.add(element.name); - - // If the field is invalid, but contains the .is-valid class, remove it. - if (element.classList.contains('is-valid')) { - element.classList.remove('is-valid'); - } // If the field is invalid, but doesn't contain the .is-invalid class, add it. if (!element.classList.contains('is-invalid')) { element.classList.add('is-invalid'); @@ -49,10 +44,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { if (element.classList.contains('is-invalid')) { element.classList.remove('is-invalid'); } - // If the field is valid, but doesn't contain the .is-valid class, add it. - if (!element.classList.contains('is-valid')) { - element.classList.add('is-valid'); - } } } diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts new file mode 100644 index 000000000..70ed4f534 --- /dev/null +++ b/netbox/project-static/src/htmx.ts @@ -0,0 +1,23 @@ +import { getElements, isTruthy } from './util'; +import { initButtons } from './buttons'; + +function initDepedencies(): void { + for (const init of [initButtons]) { + init(); + } +} + +/** + * Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps + * elements. + */ +export function initHtmx(): void { + for (const element of getElements('[hx-target]')) { + const targetSelector = element.getAttribute('hx-target'); + if (isTruthy(targetSelector)) { + for (const target of getElements(targetSelector)) { + target.addEventListener('htmx:afterSettle', initDepedencies); + } + } + } +} diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index 79c196b96..c178a2dbd 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -12,6 +12,7 @@ import { initInterfaceTable } from './tables'; import { initSideNav } from './sidenav'; import { initRackElevation } from './racks'; import { initLinks } from './links'; +import { initHtmx } from './htmx'; function initDocument(): void { for (const init of [ @@ -29,6 +30,7 @@ function initDocument(): void { initSideNav, initRackElevation, initLinks, + initHtmx, ]) { init(); } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index acbfa0646..d78429bf9 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -965,6 +965,19 @@ div.card-overlay { max-width: unset; } +/* Rendered Markdown */ +.rendered-markdown table { + width: 100%; +} +.rendered-markdown th { + border-bottom: 2px solid #dddddd; + padding: 8px; +} +.rendered-markdown td { + border-top: 1px solid #dddddd; + padding: 8px; +} + // Preformatted text blocks td pre { margin-bottom: 0 diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 50bf7133c..6e71b3995 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -104,23 +104,23 @@ {# Static resources #} @@ -129,7 +129,7 @@ {# Javascript #} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 38c1dc21b..a207558cc 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -1,8 +1,7 @@ {# Base layout for the core NetBox UI w/navbar and page content #} {% extends 'base/base.html' %} {% load helpers %} -{% load nav %} -{% load search_options %} +{% load search %} {% load static %} {% block layout %} @@ -21,7 +20,7 @@ {# Top bar #} -