Przeglądaj źródła

Merge branch 'develop' into feature

jeremystretch 4 lat temu
rodzic
commit
2dd165bbef
59 zmienionych plików z 320 dodań i 312 usunięć
  1. 4 0
      docs/models/extras/customlink.md
  2. 13 0
      docs/release-notes/version-3.1.md
  3. 3 3
      netbox/dcim/api/views.py
  4. 18 0
      netbox/extras/models/models.py
  5. 10 14
      netbox/extras/templatetags/custom_links.py
  6. 2 0
      netbox/ipam/choices.py
  7. 2 0
      netbox/ipam/forms/models.py
  8. 5 5
      netbox/netbox/authentication.py
  9. 0 0
      netbox/project-static/dist/netbox-dark.css
  10. 0 0
      netbox/project-static/dist/netbox-light.css
  11. 0 0
      netbox/project-static/dist/netbox-print.css
  12. 0 0
      netbox/project-static/dist/netbox.js
  13. 0 0
      netbox/project-static/dist/netbox.js.map
  14. 0 9
      netbox/project-static/src/forms/elements.ts
  15. 23 0
      netbox/project-static/src/htmx.ts
  16. 2 0
      netbox/project-static/src/netbox.ts
  17. 13 0
      netbox/project-static/styles/netbox.scss
  18. 5 5
      netbox/templates/base/base.html
  19. 2 3
      netbox/templates/base/layout.html
  20. 1 1
      netbox/templates/base/sidenav.html
  21. 1 1
      netbox/templates/circuits/circuit_terminations_swap.html
  22. 2 2
      netbox/templates/circuits/provider.html
  23. 1 1
      netbox/templates/dcim/bulk_disconnect.html
  24. 1 1
      netbox/templates/dcim/consoleport_delete.html
  25. 1 1
      netbox/templates/dcim/consoleserverport_delete.html
  26. 1 1
      netbox/templates/dcim/devicebay_delete.html
  27. 1 1
      netbox/templates/dcim/devicebay_depopulate.html
  28. 1 1
      netbox/templates/dcim/interface_delete.html
  29. 1 1
      netbox/templates/dcim/inventoryitem_delete.html
  30. 1 1
      netbox/templates/dcim/poweroutlet_delete.html
  31. 1 1
      netbox/templates/dcim/powerport_delete.html
  32. 1 1
      netbox/templates/dcim/virtualchassis_remove_member.html
  33. 0 0
      netbox/templates/generic/confirmation_form.html
  34. 1 1
      netbox/templates/generic/object_delete.html
  35. 1 2
      netbox/templates/home.html
  36. 1 1
      netbox/templates/inc/panels/comments.html
  37. 0 30
      netbox/templates/inc/plugin_menu_items.html
  38. 2 6
      netbox/templates/inc/profile_button.html
  39. 121 129
      netbox/templates/ipam/ipaddress.html
  40. 1 1
      netbox/templates/virtualization/virtualmachine/interfaces.html
  41. 1 0
      netbox/utilities/constants.py
  42. 39 5
      netbox/utilities/tables.py
  43. 0 0
      netbox/utilities/templates/form_helpers/render_custom_fields.html
  44. 0 0
      netbox/utilities/templates/form_helpers/render_errors.html
  45. 0 0
      netbox/utilities/templates/form_helpers/render_field.html
  46. 0 0
      netbox/utilities/templates/form_helpers/render_form.html
  47. 0 0
      netbox/utilities/templates/helpers/applied_filters.html
  48. 0 0
      netbox/utilities/templates/helpers/badge.html
  49. 0 0
      netbox/utilities/templates/helpers/table_config_form.html
  50. 0 0
      netbox/utilities/templates/helpers/tag.html
  51. 0 0
      netbox/utilities/templates/helpers/utilization_graph.html
  52. 0 0
      netbox/utilities/templates/navigation/menu.html
  53. 25 17
      netbox/utilities/templatetags/form_helpers.py
  54. 0 21
      netbox/utilities/templatetags/get_status.py
  55. 9 5
      netbox/utilities/templatetags/helpers.py
  56. 1 1
      netbox/utilities/templatetags/navigation.py
  57. 0 0
      netbox/utilities/templatetags/search.py
  58. 0 39
      netbox/utilities/utils.py
  59. 2 1
      netbox/wireless/api/serializers.py

+ 4 - 0
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
 ## 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.
 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.

+ 13 - 0
docs/release-notes/version-3.1.md

@@ -2,10 +2,23 @@
 
 
 ## v3.1.3 (FUTURE)
 ## 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
 ### 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
 * [#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
 * [#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
 
 
 ---
 ---
 
 

+ 3 - 3
netbox/dcim/api/views.py

@@ -15,14 +15,14 @@ from circuits.models import Circuit
 from dcim import filtersets
 from dcim import filtersets
 from dcim.models import *
 from dcim.models import *
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
 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.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
 from netbox.config import get_config
 from netbox.config import get_config
 from utilities.api import get_serializer_for_model
 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 virtualization.models import VirtualMachine
 from . import serializers
 from . import serializers
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
@@ -516,7 +516,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
                 response[method] = {'error': 'Only get_* NAPALM methods are supported'}
                 response[method] = {'error': 'Only get_* NAPALM methods are supported'}
                 continue
                 continue
             try:
             try:
-                response[method] = decode_dict(getattr(d, method)())
+                response[method] = getattr(d, method)()
             except NotImplementedError:
             except NotImplementedError:
                 response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
                 response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
             except Exception as e:
             except Exception as e:

+ 18 - 0
netbox/extras/models/models.py

@@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('extras:customlink', args=[self.pk])
         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')
 @extras_features('webhooks', 'export_templates')
 class ExportTemplate(ChangeLoggedModel):
 class ExportTemplate(ChangeLoggedModel):

+ 10 - 14
netbox/extras/templatetags/custom_links.py

@@ -62,16 +62,14 @@ def custom_links(context, obj):
         # Add non-grouped links
         # Add non-grouped links
         else:
         else:
             try:
             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(
                     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:
             except Exception as e:
-                template_code += '<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{}">' \
-                                 '<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name)
+                template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
+                                 f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
 
 
     # Add grouped links to template
     # Add grouped links to template
     for group, links in group_names.items():
     for group, links in group_names.items():
@@ -80,17 +78,15 @@ def custom_links(context, obj):
 
 
         for cl in links:
         for cl in links:
             try:
             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(
                     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:
             except Exception as e:
                 links_rendered.append(
                 links_rendered.append(
-                    '<li><a class="dropdown-item" disabled="disabled" title="{}"><span class="text-muted">'
-                    '<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name)
+                    f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
+                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
                 )
                 )
 
 
         if links_rendered:
         if links_rendered:

+ 2 - 0
netbox/ipam/choices.py

@@ -106,6 +106,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
     PROTOCOL_HSRP = 'hsrp'
     PROTOCOL_HSRP = 'hsrp'
     PROTOCOL_GLBP = 'glbp'
     PROTOCOL_GLBP = 'glbp'
     PROTOCOL_CARP = 'carp'
     PROTOCOL_CARP = 'carp'
+    PROTOCOL_OTHER = 'other'
 
 
     CHOICES = (
     CHOICES = (
         (PROTOCOL_VRRP2, 'VRRPv2'),
         (PROTOCOL_VRRP2, 'VRRPv2'),
@@ -113,6 +114,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
         (PROTOCOL_HSRP, 'HSRP'),
         (PROTOCOL_HSRP, 'HSRP'),
         (PROTOCOL_GLBP, 'GLBP'),
         (PROTOCOL_GLBP, 'GLBP'),
         (PROTOCOL_CARP, 'CARP'),
         (PROTOCOL_CARP, 'CARP'),
+        (PROTOCOL_OTHER, 'Other'),
     )
     )
 
 
 
 

+ 2 - 0
netbox/ipam/forms/models.py

@@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
             })
             })
         elif selected_objects:
         elif selected_objects:
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
             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.
         # Primary IP assignment is only available if an interface has been assigned.
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')

+ 5 - 5
netbox/netbox/authentication.py

@@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return settings.REMOTE_AUTH_AUTO_CREATE_USER
         return settings.REMOTE_AUTH_AUTO_CREATE_USER
 
 
     def configure_groups(self, user, remote_groups):
     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
         # Assign default groups to the user
         group_list = []
         group_list = []
@@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         Return None if ``create_unknown_user`` is ``False`` and a ``User``
         Return None if ``create_unknown_user`` is ``False`` and a ``User``
         object with the given username is not found in the database.
         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(
         logger.debug(
             f"trying to authenticate {remote_user} with groups {remote_groups}")
             f"trying to authenticate {remote_user} with groups {remote_groups}")
         if not remote_user:
         if not remote_user:
@@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend):
             return None
             return None
 
 
     def _is_superuser(self, user):
     def _is_superuser(self, user):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
         superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
         logger.debug(f"Superuser Groups: {superuser_groups}")
         logger.debug(f"Superuser Groups: {superuser_groups}")
         superusers = settings.REMOTE_AUTH_SUPERUSERS
         superusers = settings.REMOTE_AUTH_SUPERUSERS
@@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return bool(result)
         return bool(result)
 
 
     def _is_staff(self, user):
     def _is_staff(self, user):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
         staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
         logger.debug(f"Superuser Groups: {staff_groups}")
         logger.debug(f"Superuser Groups: {staff_groups}")
         staff_users = settings.REMOTE_AUTH_STAFF_USERS
         staff_users = settings.REMOTE_AUTH_STAFF_USERS
@@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return bool(result)
         return bool(result)
 
 
     def configure_user(self, request, user):
     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:
         if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
             # Assign default groups to the user
             # Assign default groups to the user
             group_list = []
             group_list = []

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 0 - 9
netbox/project-static/src/forms/elements.ts

@@ -35,11 +35,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
   for (const element of form.querySelectorAll<FormControls>('*[name]')) {
   for (const element of form.querySelectorAll<FormControls>('*[name]')) {
     if (!element.validity.valid) {
     if (!element.validity.valid) {
       invalids.add(element.name);
       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 the field is invalid, but doesn't contain the .is-invalid class, add it.
       if (!element.classList.contains('is-invalid')) {
       if (!element.classList.contains('is-invalid')) {
         element.classList.add('is-invalid');
         element.classList.add('is-invalid');
@@ -49,10 +44,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
       if (element.classList.contains('is-invalid')) {
       if (element.classList.contains('is-invalid')) {
         element.classList.remove('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');
-      }
     }
     }
   }
   }
 
 

+ 23 - 0
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);
+      }
+    }
+  }
+}

+ 2 - 0
netbox/project-static/src/netbox.ts

@@ -12,6 +12,7 @@ import { initInterfaceTable } from './tables';
 import { initSideNav } from './sidenav';
 import { initSideNav } from './sidenav';
 import { initRackElevation } from './racks';
 import { initRackElevation } from './racks';
 import { initLinks } from './links';
 import { initLinks } from './links';
+import { initHtmx } from './htmx';
 
 
 function initDocument(): void {
 function initDocument(): void {
   for (const init of [
   for (const init of [
@@ -29,6 +30,7 @@ function initDocument(): void {
     initSideNav,
     initSideNav,
     initRackElevation,
     initRackElevation,
     initLinks,
     initLinks,
+    initHtmx,
   ]) {
   ]) {
     init();
     init();
   }
   }

+ 13 - 0
netbox/project-static/styles/netbox.scss

@@ -965,6 +965,19 @@ div.card-overlay {
   max-width: unset;
   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
 // Preformatted text blocks
 td pre {
 td pre {
   margin-bottom: 0
   margin-bottom: 0

+ 5 - 5
netbox/templates/base/base.html

@@ -104,23 +104,23 @@
     {# Static resources #}
     {# Static resources #}
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
-      href="{% static 'netbox-external.css'%}"
+      href="{% static 'netbox-external.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
     />
     />
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
-      href="{% static 'netbox-light.css'%}"
+      href="{% static 'netbox-light.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-light.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-light.css'"
     />
     />
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
-      href="{% static 'netbox-dark.css'%}"
+      href="{% static 'netbox-dark.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-dark.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-dark.css'"
     />
     />
     <link
     <link
       rel="stylesheet"
       rel="stylesheet"
       media="print"
       media="print"
-      href="{% static 'netbox-print.css'%}"
+      href="{% static 'netbox-print.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
     />
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -129,7 +129,7 @@
     {# Javascript #}
     {# Javascript #}
     <script
     <script
       type="text/javascript"
       type="text/javascript"
-      src="{% static 'netbox.js' %}"
+      src="{% static 'netbox.js' %}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
     </script>
     </script>
 
 

+ 2 - 3
netbox/templates/base/layout.html

@@ -1,8 +1,7 @@
 {# Base layout for the core NetBox UI w/navbar and page content #}
 {# Base layout for the core NetBox UI w/navbar and page content #}
 {% extends 'base/base.html' %}
 {% extends 'base/base.html' %}
 {% load helpers %}
 {% load helpers %}
-{% load nav %}
-{% load search_options %}
+{% load search %}
 {% load static %}
 {% load static %}
 
 
 {% block layout %}
 {% block layout %}
@@ -21,7 +20,7 @@
         </div>
         </div>
 
 
         {# Top bar #}
         {# Top bar #}
-        <nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid noprint">
+        <nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid border-bottom bg-light bg-gradient noprint">
 
 
             {# Mobile Navigation #}
             {# Mobile Navigation #}
             <div class="nav-mobile">
             <div class="nav-mobile">

+ 1 - 1
netbox/templates/base/sidenav.html

@@ -1,4 +1,4 @@
-{% load nav %}
+{% load navigation %}
 {% load static %}
 {% load static %}
 
 
 <nav class="sidenav noprint" id="sidenav" data-simplebar>
 <nav class="sidenav noprint" id="sidenav" data-simplebar>

+ 1 - 1
netbox/templates/circuits/circuit_terminations_swap.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 
 
 {% block title %}Swap Circuit Terminations{% endblock %}
 {% block title %}Swap Circuit Terminations{% endblock %}
 
 

+ 2 - 2
netbox/templates/circuits/provider.html

@@ -41,11 +41,11 @@
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">NOC Contact</th>
                         <th scope="row">NOC Contact</th>
-                        <td class="rendered-markdown">{{ object.noc_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.noc_contact|render_markdown|placeholder }}</td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">Admin Contact</th>
                         <th scope="row">Admin Contact</th>
-                        <td class="rendered-markdown">{{ object.admin_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.admin_contact|render_markdown|placeholder }}</td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">Circuits</th>
                         <th scope="row">Circuits</th>

+ 1 - 1
netbox/templates/dcim/bulk_disconnect.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
 {% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}

+ 1 - 1
netbox/templates/dcim/consoleport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete console port {{ consoleport }}?{% endblock %}
 {% block title %}Delete console port {{ consoleport }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/consoleserverport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete console server port {{ consoleserverport }}?{% endblock %}
 {% block title %}Delete console server port {{ consoleserverport }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/devicebay_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete device bay {{ devicebay }}?{% endblock %}
 {% block title %}Delete device bay {{ devicebay }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/devicebay_depopulate.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}
 {% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/interface_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete interface {{ interface }}?{% endblock %}
 {% block title %}Delete interface {{ interface }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/inventoryitem_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete inventory item {{ inventoryitem }}?{% endblock %}
 {% block title %}Delete inventory item {{ inventoryitem }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/poweroutlet_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete power outlet {{ poweroutlet }}?{% endblock %}
 {% block title %}Delete power outlet {{ poweroutlet }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/powerport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete power port {{ powerport }}?{% endblock %}
 {% block title %}Delete power port {{ powerport }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/virtualchassis_remove_member.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Remove Virtual Chassis Member?{% endblock %}
 {% block title %}Remove Virtual Chassis Member?{% endblock %}

+ 0 - 0
netbox/templates/utilities/confirmation_form.html → netbox/templates/generic/confirmation_form.html


+ 1 - 1
netbox/templates/generic/object_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Delete {{ obj_type }}?{% endblock %}
 {% block title %}Delete {{ obj_type }}?{% endblock %}

+ 1 - 2
netbox/templates/home.html

@@ -1,5 +1,4 @@
 {% extends 'base/layout.html' %}
 {% extends 'base/layout.html' %}
-{% load get_status %}
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 
@@ -24,7 +23,7 @@
 {% block title %}Home{% endblock %}
 {% block title %}Home{% endblock %}
 
 
 {% block content-wrapper %}
 {% block content-wrapper %}
-  <div class="p-3">
+  <div class="px-3">
     {# General stats #}
     {# General stats #}
     <div class="row masonry">
     <div class="row masonry">
       {% for section, items, icon in stats %}
       {% for section, items, icon in stats %}

+ 1 - 1
netbox/templates/inc/panels/comments.html

@@ -4,7 +4,7 @@
   <h5 class="card-header">
   <h5 class="card-header">
     Comments
     Comments
   </h5>
   </h5>
-  <div class="card-body rendered-markdown">
+  <div class="card-body">
     {% if object.comments %}
     {% if object.comments %}
       {{ object.comments|render_markdown }}
       {{ object.comments|render_markdown }}
     {% else %}
     {% else %}

+ 0 - 30
netbox/templates/inc/plugin_menu_items.html

@@ -1,30 +0,0 @@
-{% load helpers %}
-<li class="dropdown">
-    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Plugins <span class="caret"></span></a>
-    <ul class="dropdown-menu">
-        {% for section_name, menu_items in registry.plugin_menu_items.items %}
-            <li class="dropdown-header">{{ section_name }}</li>
-            {% for menu_item in menu_items %}
-                {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
-                    <li>
-                        {% if menu_item.buttons %}
-                            <div class="buttons float-end">
-                                {% for button in menu_item.buttons %}
-                                    {% if not button.permissions or request.user|has_perms:button.permissions %}
-                                        <a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
-                                    {% endif %}
-                                {% endfor %}
-                            </div>
-                        {% endif %}
-                        <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
-                    </li>
-                {% else %}
-                    <li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
-                {% endif %}
-            {% endfor %}
-            {% if not forloop.last %}
-                <li class="divider"></li>
-            {% endif %}
-        {% endfor %}
-    </ul>
-</li>

+ 2 - 6
netbox/templates/inc/profile_button.html

@@ -30,7 +30,7 @@
     </li>
     </li>
     <li><hr class="dropdown-divider" /></li>
     <li><hr class="dropdown-divider" /></li>
     <li>
     <li>
-      <a class="dropdown-item text-danger" href="{% url 'logout' %}">
+      <a class="dropdown-item" href="{% url 'logout' %}">
         <i class="mdi mdi-logout-variant"></i> Log Out
         <i class="mdi mdi-logout-variant"></i> Log Out
       </a>
       </a>
     </li>
     </li>
@@ -38,11 +38,7 @@
 </span>
 </span>
 {% else %}
 {% else %}
 <div class="btn-group">
 <div class="btn-group">
-  <a
-    class="btn btn-primary ws-nowrap"
-    type="button"
-    href="{% url 'login' %}"
-  >
+  <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
     <i class="mdi mdi-login-variant"></i> Log In
     <i class="mdi mdi-login-variant"></i> Log In
   </a>
   </a>
   <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">
   <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">

+ 121 - 129
netbox/templates/ipam/ipaddress.html

@@ -13,143 +13,135 @@
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
 	<div class="col col-md-4">
 	<div class="col col-md-4">
-        <div class="card">
-            <h5 class="card-header">
-                IP Address
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">Family</th>
-                        <td>IPv{{ object.family }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">VRF</th>
-                        <td>
-                            {% if object.vrf %}
-                                <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
-                            {% else %}
-                                <span>Global</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Tenant</th>
-                        <td>
-                            {% if object.tenant %}
-                                {% if object.tenant.group %}
-                                    <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
-                                {% endif %}
-                                <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Status</th>
-                        <td>
-                            <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Role</th>
-                        <td>
-                            {% if object.role %}
-                                <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">DNS Name</th>
-                        <td>{{ object.dns_name|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Description</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Assignment</th>
-                        <td>
-                          {% if object.assigned_object %}
-                            {% if object.assigned_object.parent_object %}
-                              <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
-                            {% endif %}
-                            <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
+      <div class="card">
+          <h5 class="card-header">
+              IP Address
+          </h5>
+          <div class="card-body">
+              <table class="table table-hover attr-table">
+                  <tr>
+                      <th scope="row">Family</th>
+                      <td>IPv{{ object.family }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">VRF</th>
+                      <td>
+                          {% if object.vrf %}
+                              <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
                           {% else %}
                           {% else %}
-                            <span class="text-muted">&mdash;</span>
+                              <span>Global</span>
                           {% endif %}
                           {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">NAT (inside)</th>
-                        <td>
-                            {% if object.nat_inside %}
-                                <a href="{{ object.nat_inside.get_absolute_url }}">{{ object.nat_inside }}</a>
-                                {% if object.nat_inside.assigned_object %}
-                                    (<a href="{{ object.nat_inside.assigned_object.parent_object.get_absolute_url }}">{{ object.nat_inside.assigned_object.parent_object }}</a>)
-                                {% endif %}
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">NAT (outside)</th>
-                        <td>
-                            {% if object.nat_outside %}
-                                <a href="{{ object.nat_outside.get_absolute_url }}">{{ object.nat_outside }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                </table>
-            </div>
-        </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        
-        {% plugin_left_page object %}
-	</div>
-    
-	<div class="col col-md-8">
-        {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
-        {% if duplicate_ips_table.rows %}
-            {# Custom version of panel_table.html #}
-            <div class="card border-danger">
-                <h5 class="card-header">
-                  <span class="text-danger">Duplicate IP Addresses</span>
-                    {% if more_duplicate_ips %}
-                      <div class="float-end">
-                        <a type="button" class="btn btn-primary btn-sm"
-                        {% if object.vrf %}
-                        href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id={{ object.vrf.pk }}"
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Tenant</th>
+                      <td>
+                          {% if object.tenant %}
+                              {% if object.tenant.group %}
+                                  <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
+                              {% endif %}
+                              <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Status</th>
+                      <td>
+                          <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Role</th>
+                      <td>
+                          {% if object.role %}
+                              <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">DNS Name</th>
+                      <td>{{ object.dns_name|placeholder }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Description</th>
+                      <td>{{ object.description|placeholder }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Assignment</th>
+                      <td>
+                        {% if object.assigned_object %}
+                          {% if object.assigned_object.parent_object %}
+                            <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
+                          {% endif %}
+                          <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
                         {% else %}
                         {% else %}
-                        href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id=null"
+                          <span class="text-muted">&mdash;</span>
                         {% endif %}
                         {% endif %}
-                        >Show all</a>
-                      </div>
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">NAT (inside)</th>
+                      <td>
+                          {% if object.nat_inside %}
+                              <a href="{{ object.nat_inside.get_absolute_url }}">{{ object.nat_inside }}</a>
+                              {% if object.nat_inside.assigned_object %}
+                                  (<a href="{{ object.nat_inside.assigned_object.parent_object.get_absolute_url }}">{{ object.nat_inside.assigned_object.parent_object }}</a>)
+                              {% endif %}
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">NAT (outside)</th>
+                      <td>
+                          {% if object.nat_outside %}
+                              <a href="{{ object.nat_outside.get_absolute_url }}">{{ object.nat_outside }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+              </table>
+          </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-8">
+    {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
+    {% if duplicate_ips_table.rows %}
+        {# Custom version of panel_table.html #}
+        <div class="card border-danger">
+            <h5 class="card-header">
+              <span class="text-danger">Duplicate IP Addresses</span>
+                {% if more_duplicate_ips %}
+                  <div class="float-end">
+                    <a type="button" class="btn btn-primary btn-sm"
+                    {% if object.vrf %}
+                    href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id={{ object.vrf.pk }}"
+                    {% else %}
+                    href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id=null"
                     {% endif %}
                     {% endif %}
-                </h5>
-                <div class="card-body table-responsive">
-                  {% render_table duplicate_ips_table 'inc/table.html' %}
-                </div>
+                    >Show all</a>
+                  </div>
+                {% endif %}
+            </h5>
+            <div class="card-body table-responsive">
+              {% render_table duplicate_ips_table 'inc/table.html' %}
             </div>
             </div>
-        {% endif %}
-        <div class="my-3">
-        {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
         </div>
         </div>
-        {% plugin_right_page object %}
-	</div>
-</div>
-
-<div class="row my-3">
-    <div class="col col-md-4">
-        {% include 'inc/panels/tags.html' %}
+    {% endif %}
+    <div class="my-3">
+      {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
     </div>
     </div>
-    
+    {% plugin_right_page object %}
+	</div>
 </div>
 </div>
 
 
 <div class="row">
 <div class="row">

+ 1 - 1
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -5,7 +5,7 @@
 {% block content %}
 {% block content %}
   <form method="post">
   <form method="post">
     {% csrf_token %}
     {% csrf_token %}
-    {% include 'inc/table_controls_htmx.html' with table_modal="VMInterfaceTable_config" %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
 
 
     <div class="card">
     <div class="card">
       <div class="card-body" id="object_list">
       <div class="card-body" id="object_list">

+ 1 - 0
netbox/utilities/constants.py

@@ -57,6 +57,7 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'HTTP_HOST',
     'HTTP_HOST',
     'HTTP_REFERER',
     'HTTP_REFERER',
     'HTTP_USER_AGENT',
     'HTTP_USER_AGENT',
+    'HTTP_X_FORWARDED_FOR',
     'QUERY_STRING',
     'QUERY_STRING',
     'REMOTE_ADDR',
     'REMOTE_ADDR',
     'REMOTE_HOST',
     'REMOTE_HOST',

+ 39 - 5
netbox/utilities/tables.py

@@ -12,7 +12,7 @@ from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
-from extras.models import CustomField
+from extras.models import CustomField, CustomLink
 from .utils import content_type_identifier, content_type_name
 from .utils import content_type_identifier, content_type_name
 from .paginator import EnhancedPaginator, get_paginate_count
 from .paginator import EnhancedPaginator, get_paginate_count
 
 
@@ -34,15 +34,18 @@ class BaseTable(tables.Table):
         }
         }
 
 
     def __init__(self, *args, user=None, extra_columns=None, **kwargs):
     def __init__(self, *args, user=None, extra_columns=None, **kwargs):
+        if extra_columns is None:
+            extra_columns = []
+
         # Add custom field columns
         # Add custom field columns
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         cf_columns = [
         cf_columns = [
             (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
             (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
         ]
         ]
-        if extra_columns is not None:
-            extra_columns.extend(cf_columns)
-        else:
-            extra_columns = cf_columns
+        cl_columns = [
+            (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type)
+        ]
+        extra_columns.extend([*cf_columns, *cl_columns])
 
 
         super().__init__(*args, extra_columns=extra_columns, **kwargs)
         super().__init__(*args, extra_columns=extra_columns, **kwargs)
 
 
@@ -418,6 +421,37 @@ class CustomFieldColumn(tables.Column):
         return self.default
         return self.default
 
 
 
 
+class CustomLinkColumn(tables.Column):
+    """
+    Render a custom links as a table column.
+    """
+    def __init__(self, customlink, *args, **kwargs):
+        self.customlink = customlink
+        kwargs['accessor'] = Accessor('pk')
+        if 'verbose_name' not in kwargs:
+            kwargs['verbose_name'] = customlink.name
+
+        super().__init__(*args, **kwargs)
+
+    def render(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
+        except Exception as e:
+            return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
+        return ''
+
+    def value(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return rendered['link']
+        except Exception:
+            pass
+        return None
+
+
 class MPTTColumn(tables.TemplateColumn):
 class MPTTColumn(tables.TemplateColumn):
     """
     """
     Display a nested hierarchy for MPTT-enabled models.
     Display a nested hierarchy for MPTT-enabled models.

+ 0 - 0
netbox/templates/utilities/render_custom_fields.html → netbox/utilities/templates/form_helpers/render_custom_fields.html


+ 0 - 0
netbox/templates/utilities/render_errors.html → netbox/utilities/templates/form_helpers/render_errors.html


+ 0 - 0
netbox/templates/utilities/render_field.html → netbox/utilities/templates/form_helpers/render_field.html


+ 0 - 0
netbox/templates/utilities/render_form.html → netbox/utilities/templates/form_helpers/render_form.html


+ 0 - 0
netbox/templates/utilities/templatetags/applied_filters.html → netbox/utilities/templates/helpers/applied_filters.html


+ 0 - 0
netbox/templates/utilities/templatetags/badge.html → netbox/utilities/templates/helpers/badge.html


+ 0 - 0
netbox/templates/utilities/templatetags/table_config_form.html → netbox/utilities/templates/helpers/table_config_form.html


+ 0 - 0
netbox/templates/utilities/templatetags/tag.html → netbox/utilities/templates/helpers/tag.html


+ 0 - 0
netbox/templates/utilities/templatetags/utilization_graph.html → netbox/utilities/templates/helpers/utilization_graph.html


+ 0 - 0
netbox/utilities/templates/navigation/nav_items.html → netbox/utilities/templates/navigation/menu.html


+ 25 - 17
netbox/utilities/templatetags/form_helpers.py

@@ -4,6 +4,10 @@ from django import template
 register = template.Library()
 register = template.Library()
 
 
 
 
+#
+# Filters
+#
+
 @register.filter()
 @register.filter()
 def getfield(form, fieldname):
 def getfield(form, fieldname):
     """
     """
@@ -12,7 +16,24 @@ def getfield(form, fieldname):
     return form[fieldname]
     return form[fieldname]
 
 
 
 
-@register.inclusion_tag('utilities/render_field.html')
+@register.filter(name='widget_type')
+def widget_type(field):
+    """
+    Return the widget type
+    """
+    if hasattr(field, 'widget'):
+        return field.widget.__class__.__name__.lower()
+    elif hasattr(field, 'field'):
+        return field.field.widget.__class__.__name__.lower()
+    else:
+        return None
+
+
+#
+# Inclusion tags
+#
+
+@register.inclusion_tag('form_helpers/render_field.html')
 def render_field(field, bulk_nullable=False, label=None):
 def render_field(field, bulk_nullable=False, label=None):
     """
     """
     Render a single form field from template
     Render a single form field from template
@@ -24,7 +45,7 @@ def render_field(field, bulk_nullable=False, label=None):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/render_custom_fields.html')
+@register.inclusion_tag('form_helpers/render_custom_fields.html')
 def render_custom_fields(form):
 def render_custom_fields(form):
     """
     """
     Render all custom fields in a form
     Render all custom fields in a form
@@ -34,7 +55,7 @@ def render_custom_fields(form):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/render_form.html')
+@register.inclusion_tag('form_helpers/render_form.html')
 def render_form(form):
 def render_form(form):
     """
     """
     Render an entire form from template
     Render an entire form from template
@@ -44,20 +65,7 @@ def render_form(form):
     }
     }
 
 
 
 
-@register.filter(name='widget_type')
-def widget_type(field):
-    """
-    Return the widget type
-    """
-    if hasattr(field, 'widget'):
-        return field.widget.__class__.__name__.lower()
-    elif hasattr(field, 'field'):
-        return field.field.widget.__class__.__name__.lower()
-    else:
-        return None
-
-
-@register.inclusion_tag('utilities/render_errors.html')
+@register.inclusion_tag('form_helpers/render_errors.html')
 def render_errors(form):
 def render_errors(form):
     """
     """
     Render form errors, if they exist.
     Render form errors, if they exist.

+ 0 - 21
netbox/utilities/templatetags/get_status.py

@@ -1,21 +0,0 @@
-from django import template
-
-register = template.Library()
-
-TERMS_DANGER = ("delete", "deleted", "remove", "removed")
-TERMS_WARNING = ("changed", "updated", "change", "update")
-TERMS_SUCCESS = ("created", "added", "create", "add")
-
-
-@register.simple_tag
-def get_status(text: str) -> str:
-    lower = text.lower()
-
-    if lower in TERMS_DANGER:
-        return "danger"
-    elif lower in TERMS_WARNING:
-        return "warning"
-    elif lower in TERMS_SUCCESS:
-        return "success"
-    else:
-        return "info"

+ 9 - 5
netbox/utilities/templatetags/helpers.py

@@ -59,6 +59,10 @@ def render_markdown(value):
     # Render Markdown
     # Render Markdown
     html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
     html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
 
 
+    # If the string is not empty wrap it in rendered-markdown to style tables
+    if html:
+        html = f'<div class="rendered-markdown">{html}</div>'
+
     return mark_safe(html)
     return mark_safe(html)
 
 
 
 
@@ -380,7 +384,7 @@ def querystring(request, **kwargs):
         return ''
         return ''
 
 
 
 
-@register.inclusion_tag('utilities/templatetags/utilization_graph.html')
+@register.inclusion_tag('helpers/utilization_graph.html')
 def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
 def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     """
     """
     Display a horizontal bar graph indicating a percentage of utilization.
     Display a horizontal bar graph indicating a percentage of utilization.
@@ -399,7 +403,7 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/templatetags/tag.html')
+@register.inclusion_tag('helpers/tag.html')
 def tag(tag, url_name=None):
 def tag(tag, url_name=None):
     """
     """
     Display a tag, optionally linked to a filtered list of objects.
     Display a tag, optionally linked to a filtered list of objects.
@@ -410,7 +414,7 @@ def tag(tag, url_name=None):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/templatetags/badge.html')
+@register.inclusion_tag('helpers/badge.html')
 def badge(value, bg_class='secondary', show_empty=False):
 def badge(value, bg_class='secondary', show_empty=False):
     """
     """
     Display the specified number as a badge.
     Display the specified number as a badge.
@@ -422,7 +426,7 @@ def badge(value, bg_class='secondary', show_empty=False):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/templatetags/table_config_form.html')
+@register.inclusion_tag('helpers/table_config_form.html')
 def table_config_form(table, table_name=None):
 def table_config_form(table, table_name=None):
     return {
     return {
         'table_name': table_name or table.__class__.__name__,
         'table_name': table_name or table.__class__.__name__,
@@ -430,7 +434,7 @@ def table_config_form(table, table_name=None):
     }
     }
 
 
 
 
-@register.inclusion_tag('utilities/templatetags/applied_filters.html')
+@register.inclusion_tag('helpers/applied_filters.html')
 def applied_filters(form, query_params):
 def applied_filters(form, query_params):
     """
     """
     Display the active filters for a given filter form.
     Display the active filters for a given filter form.

+ 1 - 1
netbox/utilities/templatetags/nav.py → netbox/utilities/templatetags/navigation.py

@@ -8,7 +8,7 @@ from netbox.navigation_menu import MENUS
 register = template.Library()
 register = template.Library()
 
 
 
 
-@register.inclusion_tag("navigation/nav_items.html", takes_context=True)
+@register.inclusion_tag("navigation/menu.html", takes_context=True)
 def nav(context: Context) -> Dict:
 def nav(context: Context) -> Dict:
     """
     """
     Render the navigation menu.
     Render the navigation menu.

+ 0 - 0
netbox/utilities/templatetags/search_options.py → netbox/utilities/templatetags/search.py


+ 0 - 39
netbox/utilities/utils.py

@@ -288,45 +288,6 @@ def flatten_dict(d, prefix='', separator='.'):
     return ret
     return ret
 
 
 
 
-def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict:
-    """
-    Recursively URL decode string keys and values of a dict.
-
-    For example, `{'1%2F1%2F1': {'1%2F1%2F2': ['1%2F1%2F3', '1%2F1%2F4']}}` would
-    become: `{'1/1/1': {'1/1/2': ['1/1/3', '1/1/4']}}`
-
-    :param encoded_dict: Dictionary to be decoded.
-    :param decode_keys: (Optional) Enable/disable decoding of dict keys.
-    """
-
-    def decode_value(value: Any, _decode_keys: bool) -> Any:
-        """
-        Handle URL decoding of any supported value type.
-        """
-        # Decode string values.
-        if isinstance(value, str):
-            return urllib.parse.unquote(value)
-        # Recursively decode each list item.
-        elif isinstance(value, list):
-            return [decode_value(v, _decode_keys) for v in value]
-        # Recursively decode each tuple item.
-        elif isinstance(value, Tuple):
-            return tuple(decode_value(v, _decode_keys) for v in value)
-        # Recursively decode each dict key/value pair.
-        elif isinstance(value, dict):
-            # Don't decode keys, if `decode_keys` is false.
-            if not _decode_keys:
-                return {k: decode_value(v, _decode_keys) for k, v in value.items()}
-            return {urllib.parse.unquote(k): decode_value(v, _decode_keys) for k, v in value.items()}
-        return value
-
-    if not decode_keys:
-        # Don't decode keys, if `decode_keys` is false.
-        return {k: decode_value(v, decode_keys) for k, v in encoded_dict.items()}
-
-    return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()}
-
-
 def array_to_string(array):
 def array_to_string(array):
     """
     """
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.

+ 2 - 1
netbox/wireless/api/serializers.py

@@ -40,6 +40,7 @@ class WirelessLANSerializer(PrimaryModelSerializer):
         model = WirelessLAN
         model = WirelessLAN
         fields = [
         fields = [
             'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk',
             'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -55,5 +56,5 @@ class WirelessLinkSerializer(PrimaryModelSerializer):
         model = WirelessLink
         model = WirelessLink
         fields = [
         fields = [
             'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type',
             'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type',
-            'auth_cipher', 'auth_psk',
+            'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików