mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
2dd165bbef
@ -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.
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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 = []
|
||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -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
netbox/project-static/src/htmx.ts
Normal file
23
netbox/project-static/src/htmx.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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,4 +1,4 @@
|
||||
{% load nav %}
|
||||
{% load navigation %}
|
||||
{% load static %}
|
||||
|
||||
<nav class="sidenav noprint" id="sidenav" data-simplebar>
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
|
||||
{% block title %}Swap Circuit Terminations{% endblock %}
|
||||
|
||||
|
@ -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,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
|
||||
|
@ -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,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,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete device bay {{ devicebay }}?{% endblock %}
|
||||
|
@ -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,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete interface {{ interface }}?{% endblock %}
|
||||
|
@ -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,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete power outlet {{ poweroutlet }}?{% endblock %}
|
||||
|
@ -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,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Remove Virtual Chassis Member?{% endblock %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% extends 'generic/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete {{ obj_type }}?{% endblock %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
@ -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">
|
||||
|
@ -109,11 +109,10 @@
|
||||
</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 %}
|
||||
@ -145,13 +144,6 @@
|
||||
</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="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
|
@ -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">
|
||||
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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"
|
@ -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.
|
||||
|
@ -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.
|
@ -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.
|
||||
|
@ -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',
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user