Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-12-23 08:32:40 -05:00
commit 2dd165bbef
59 changed files with 338 additions and 330 deletions

View File

@ -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.

View File

@ -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
--- ---

View File

@ -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:

View File

@ -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):

View File

@ -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) rendered = cl.render(link_context)
if text_rendered: if rendered:
link_rendered = render_jinja2(cl.link_url, link_context)
link_target = ' target="_blank"' if cl.new_window else ''
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="{}">' \ template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
'<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name) 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) rendered = cl.render(link_context)
if text_rendered: if rendered:
link_target = ' target="_blank"' if cl.new_window else ''
link_rendered = render_jinja2(cl.link_url, link_context)
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">' f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
'<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name) f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
) )
if links_rendered: if links_rendered:

View File

@ -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'),
) )

View File

@ -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')

View File

@ -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 = []

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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');
}
} }
} }

View File

@ -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);
}
}
}
}

View File

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

View File

@ -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

View File

@ -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>

View File

@ -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 %}
{% load search_options %}
{% 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">

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
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">

View File

@ -109,11 +109,10 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-8"> <div class="col col-md-8">
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
@ -145,13 +144,6 @@
</div> </div>
</div> </div>
<div class="row my-3">
<div class="col col-md-4">
{% include 'inc/panels/tags.html' %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -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">

View File

@ -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',

View File

@ -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: cl_columns = [
extra_columns.extend(cf_columns) (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type)
else: ]
extra_columns = cf_columns 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.

View File

@ -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,38 +16,6 @@ def getfield(form, fieldname):
return form[fieldname] return form[fieldname]
@register.inclusion_tag('utilities/render_field.html')
def render_field(field, bulk_nullable=False, label=None):
"""
Render a single form field from template
"""
return {
'field': field,
'label': label,
'bulk_nullable': bulk_nullable,
}
@register.inclusion_tag('utilities/render_custom_fields.html')
def render_custom_fields(form):
"""
Render all custom fields in a form
"""
return {
'form': form,
}
@register.inclusion_tag('utilities/render_form.html')
def render_form(form):
"""
Render an entire form from template
"""
return {
'form': form,
}
@register.filter(name='widget_type') @register.filter(name='widget_type')
def widget_type(field): def widget_type(field):
""" """
@ -57,7 +29,43 @@ def widget_type(field):
return None return None
@register.inclusion_tag('utilities/render_errors.html') #
# 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
"""
return {
'field': field,
'label': label,
'bulk_nullable': bulk_nullable,
}
@register.inclusion_tag('form_helpers/render_custom_fields.html')
def render_custom_fields(form):
"""
Render all custom fields in a form
"""
return {
'form': form,
}
@register.inclusion_tag('form_helpers/render_form.html')
def render_form(form):
"""
Render an entire form from template
"""
return {
'form': form,
}
@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.

View File

@ -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"

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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',
] ]