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
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)
### 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
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{% extends 'utilities/confirmation_form.html' %}
{% extends 'generic/confirmation_form.html' %}
{% load helpers %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% block title %}Delete {{ obj_type }}?{% endblock %}

View File

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

View File

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

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

View File

@ -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>
{% 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>
{% plugin_right_page object %}
</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>
<div class="row my-3">
<div class="col col-md-4">
{% include 'inc/panels/tags.html' %}
<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 %}
>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>
{% plugin_right_page object %}
</div>
</div>
<div class="row">

View File

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

View File

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

View File

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

View File

@ -4,6 +4,10 @@ from django import template
register = template.Library()
#
# Filters
#
@register.filter()
def getfield(form, fieldname):
"""
@ -12,38 +16,6 @@ def getfield(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')
def widget_type(field):
"""
@ -57,7 +29,43 @@ def widget_type(field):
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):
"""
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
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.

View File

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

View File

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

View File

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