Просмотр исходного кода

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
2dd165bbef
59 измененных файлов с 320 добавлено и 312 удалено
  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
 
 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)
 
+### 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
 
 ---
 

+ 3 - 3
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:

+ 18 - 0
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):

+ 10 - 14
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 += '<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
     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(
-                    '<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:

+ 2 - 0
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'),
     )
 
 

+ 2 - 0
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')

+ 5 - 5
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 = []

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 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]')) {
     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');
-      }
     }
   }
 

+ 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 { 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();
   }

+ 13 - 0
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

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

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

+ 2 - 3
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 @@
         </div>
 
         {# 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 #}
             <div class="nav-mobile">

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

@@ -1,4 +1,4 @@
-{% load nav %}
+{% load navigation %}
 {% load static %}
 
 <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 %}
 

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

@@ -41,11 +41,11 @@
                     </tr>
                     <tr>
                         <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>
                         <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>
                         <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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% 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 %}
 
 {% block title %}Delete {{ obj_type }}?{% endblock %}

+ 1 - 2
netbox/templates/home.html

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

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

@@ -4,7 +4,7 @@
   <h5 class="card-header">
     Comments
   </h5>
-  <div class="card-body rendered-markdown">
+  <div class="card-body">
     {% if object.comments %}
       {{ object.comments|render_markdown }}
     {% 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><hr class="dropdown-divider" /></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
       </a>
     </li>
@@ -38,11 +38,7 @@
 </span>
 {% else %}
 <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
   </a>
   <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 %}
 <div class="row">
 	<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 %}
-                            <span class="text-muted">&mdash;</span>
+                              <span>Global</span>
                           {% 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 %}
-                        href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id=null"
+                          <span class="text-muted">&mdash;</span>
                         {% 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 %}
-                </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>
-        {% endif %}
-        <div class="my-3">
-        {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
         </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>
-    
+    {% plugin_right_page object %}
+	</div>
 </div>
 
 <div class="row">

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

@@ -5,7 +5,7 @@
 {% block content %}
   <form method="post">
     {% 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-body" id="object_list">

+ 1 - 0
netbox/utilities/constants.py

@@ -57,6 +57,7 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'HTTP_HOST',
     'HTTP_REFERER',
     'HTTP_USER_AGENT',
+    'HTTP_X_FORWARDED_FOR',
     'QUERY_STRING',
     'REMOTE_ADDR',
     '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 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 .paginator import EnhancedPaginator, get_paginate_count
 
@@ -34,15 +34,18 @@ class BaseTable(tables.Table):
         }
 
     def __init__(self, *args, user=None, extra_columns=None, **kwargs):
+        if extra_columns is None:
+            extra_columns = []
+
         # Add custom field columns
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         cf_columns = [
             (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)
 
@@ -418,6 +421,37 @@ class CustomFieldColumn(tables.Column):
         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):
     """
     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()
 
 
+#
+# Filters
+#
+
 @register.filter()
 def getfield(form, fieldname):
     """
@@ -12,7 +16,24 @@ def getfield(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):
     """
     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):
     """
     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):
     """
     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):
     """
     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
     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)
 
 
@@ -380,7 +384,7 @@ def querystring(request, **kwargs):
         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):
     """
     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):
     """
     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):
     """
     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):
     return {
         '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):
     """
     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.inclusion_tag("navigation/nav_items.html", takes_context=True)
+@register.inclusion_tag("navigation/menu.html", takes_context=True)
 def nav(context: Context) -> Dict:
     """
     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
 
 
-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):
     """
     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
         fields = [
             '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
         fields = [
             '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',
         ]

Некоторые файлы не были показаны из-за большого количества измененных файлов