mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-26 15:17:45 -06:00
Merge branch 'develop' into feature
This commit is contained in:
@@ -19,6 +19,7 @@ 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.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.config import get_config
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
pagination_class = StripCountAnnotationsPaginator
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
|
||||
@@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
qs_filter |= Q(asns__asn=int(value.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
|
||||
@@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, VLAN, VRF
|
||||
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm(
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
||||
'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -114,7 +114,7 @@ class RackElevationSVG:
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
image = drawing.image(
|
||||
href=device.device_type.front_image.url,
|
||||
href='{}{}'.format(self.base_url, device.device_type.front_image.url),
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -140,7 +140,7 @@ class RackElevationSVG:
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
image = drawing.image(
|
||||
href=device.device_type.rear_image.url,
|
||||
href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -151,9 +151,9 @@ class RackElevationSVG:
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}?{}'.format(
|
||||
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}{}?{}'.format(
|
||||
self.base_url,
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': rack.site.pk,
|
||||
|
||||
@@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(
|
||||
role=instance
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
|
||||
racks_table = tables.RackTable(racks, user=request.user, exclude=(
|
||||
|
||||
@@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
|
||||
from extras.context_managers import change_logging
|
||||
from extras.models import JobResult
|
||||
from extras.scripts import get_script
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
@@ -49,7 +50,7 @@ class Command(BaseCommand):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -58,7 +59,7 @@ class Command(BaseCommand):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.utils.functional import classproperty
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.signals import clear_webhooks
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
@@ -465,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -474,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
@@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
self.count = queryset.count()
|
||||
self.count = self.get_queryset_count(queryset)
|
||||
else:
|
||||
# We're dealing with an iterable, not a QuerySet
|
||||
self.count = len(queryset)
|
||||
@@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
return self.default_limit
|
||||
|
||||
def get_queryset_count(self, queryset):
|
||||
return queryset.count()
|
||||
|
||||
def get_next_link(self):
|
||||
|
||||
# Pagination has been disabled
|
||||
@@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return None
|
||||
|
||||
return super().get_previous_link()
|
||||
|
||||
|
||||
class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination):
|
||||
"""
|
||||
Strips the annotations on the queryset before getting the count
|
||||
to optimize pagination of complex queries.
|
||||
"""
|
||||
def get_queryset_count(self, queryset):
|
||||
# Clone the queryset to avoid messing up the actual query
|
||||
cloned_queryset = queryset.all()
|
||||
cloned_queryset.query.annotations.clear()
|
||||
|
||||
return cloned_queryset.count()
|
||||
|
||||
@@ -36,3 +36,8 @@ REDIS = {
|
||||
}
|
||||
|
||||
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
|
||||
@@ -166,6 +166,7 @@ class ActionsItem:
|
||||
title: str
|
||||
icon: str
|
||||
permission: Optional[str] = None
|
||||
css_class: Optional[str] = 'secondary'
|
||||
|
||||
|
||||
class ActionsColumn(tables.Column):
|
||||
@@ -175,19 +176,22 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
:param actions: The ordered list of dropdown menu items to include
|
||||
:param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
|
||||
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
|
||||
direct button link and icon (default: True)
|
||||
"""
|
||||
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||
empty_values = ()
|
||||
actions = {
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
|
||||
'changelog': ActionsItem('Changelog', 'history'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.extra_buttons = extra_buttons
|
||||
self.split_actions = split_actions
|
||||
|
||||
# Determine which actions to enable
|
||||
self.actions = {
|
||||
@@ -208,22 +212,49 @@ class ActionsColumn(tables.Column):
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
links = []
|
||||
button = None
|
||||
dropdown_class = 'secondary'
|
||||
dropdown_links = []
|
||||
user = getattr(request, 'user', AnonymousUser())
|
||||
for action, attrs in self.actions.items():
|
||||
for idx, (action, attrs) in enumerate(self.actions.items()):
|
||||
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||
if attrs.permission is None or user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
||||
links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
if links:
|
||||
|
||||
# Render a separate button if a) only one action exists, or b) if split_actions is True
|
||||
if len(self.actions) == 1 or (self.split_actions and idx == 0):
|
||||
dropdown_class = attrs.css_class
|
||||
button = (
|
||||
f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
|
||||
)
|
||||
|
||||
# Add dropdown menu items
|
||||
else:
|
||||
dropdown_links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
|
||||
# Create the actions dropdown menu
|
||||
if button and dropdown_links:
|
||||
html += (
|
||||
f'<span class="dropdown">'
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
|
||||
f'<i class="mdi mdi-wrench"></i></a>'
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' {button}'
|
||||
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
elif button:
|
||||
html += button
|
||||
elif dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
|
||||
# Render any extra buttons from template code
|
||||
|
||||
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
|
||||
import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@@ -10,6 +11,7 @@ export function initButtons(): void {
|
||||
initConnectionToggle,
|
||||
initReslug,
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
]) {
|
||||
func();
|
||||
|
||||
105
netbox/project-static/src/buttons/selectMultiple.ts
Normal file
105
netbox/project-static/src/buttons/selectMultiple.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getElements } from '../util';
|
||||
import { StateManager } from 'src/state';
|
||||
import { previousPkCheckState } from '../stores';
|
||||
|
||||
type PreviousPkCheckState = { element: Nullable<HTMLInputElement> };
|
||||
|
||||
/**
|
||||
* If there is a text selection, removes it.
|
||||
*/
|
||||
function removeTextSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state object passed in to the eventTargetElement object passed in.
|
||||
*
|
||||
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||
* event passed in from handlePkCheck()
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function updatePreviousPkCheckState(
|
||||
eventTargetElement: HTMLInputElement,
|
||||
state: StateManager<PreviousPkCheckState>,
|
||||
): void {
|
||||
state.set('element', eventTargetElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle
|
||||
* "checked" value to eventTargetElement.checked
|
||||
*
|
||||
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||
* event passed in from handlePkCheck()
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function toggleCheckboxRange(
|
||||
eventTargetElement: HTMLInputElement,
|
||||
previousStateElement: HTMLInputElement,
|
||||
elementList: Generator,
|
||||
): void {
|
||||
let changePkCheckboxState = false;
|
||||
for (const element of elementList) {
|
||||
const typedElement = element as HTMLInputElement;
|
||||
//Change loop's current checkbox state to eventTargetElement checkbox state
|
||||
if (changePkCheckboxState === true) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
//The previously clicked checkbox was above the shift clicked checkbox
|
||||
if (element === previousStateElement) {
|
||||
if (changePkCheckboxState === true) {
|
||||
changePkCheckboxState = false;
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
//The previously clicked checkbox was below the shift clicked checkbox
|
||||
if (element === eventTargetElement) {
|
||||
if (changePkCheckboxState === true) {
|
||||
changePkCheckboxState = false;
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the
|
||||
* event target element and the state element.
|
||||
*
|
||||
* @param event Mouse event.
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function handlePkCheck(event: MouseEvent, state: StateManager<PreviousPkCheckState>): void {
|
||||
const eventTargetElement = event.target as HTMLInputElement;
|
||||
const previousStateElement = state.get('element');
|
||||
updatePreviousPkCheckState(eventTargetElement, state);
|
||||
//Stop if user is not holding shift key
|
||||
if (!event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
removeTextSelection();
|
||||
//If no previous state, store event target element as previous state and return
|
||||
if (previousStateElement === null) {
|
||||
return updatePreviousPkCheckState(eventTargetElement, state);
|
||||
}
|
||||
const checkboxList = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||
toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize table select all elements.
|
||||
*/
|
||||
export function initSelectMultiple(): void {
|
||||
const checkboxElements = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||
for (const element of checkboxElements) {
|
||||
element.addEventListener('click', event => {
|
||||
removeTextSelection();
|
||||
//Stop propogation to avoid event firing multiple times
|
||||
event.stopPropagation();
|
||||
handlePkCheck(event, previousPkCheckState);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,11 @@ export class APISelect {
|
||||
onChange: () => this.handleSlimChange(),
|
||||
});
|
||||
|
||||
// Don't close on select if multiple select
|
||||
if (this.base.multiple) {
|
||||
this.slim.config.closeOnSelect = false;
|
||||
}
|
||||
|
||||
// Initialize API query properties.
|
||||
this.getStaticParams();
|
||||
this.getDynamicParams();
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './objectDepth';
|
||||
export * from './rackImages';
|
||||
export * from './previousPkCheck';
|
||||
|
||||
6
netbox/project-static/src/stores/previousPkCheck.ts
Normal file
6
netbox/project-static/src/stores/previousPkCheck.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createState } from '../state';
|
||||
|
||||
export const previousPkCheckState = createState<{ element: Nullable<HTMLInputElement> }>(
|
||||
{ element: null },
|
||||
{ persist: false },
|
||||
);
|
||||
@@ -10,7 +10,7 @@
|
||||
{% if termination_a %}
|
||||
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if termination_z %}
|
||||
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
{% elif termination.port_speed %}
|
||||
{{ termination.port_speed|humanize_speed }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{% if object.portal_url %}
|
||||
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{% if object.color %}
|
||||
<span class="color-label" style="background-color: #{{ object.color }}"> </span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -50,7 +50,7 @@
|
||||
{% if object.length %}
|
||||
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endfor %}
|
||||
{{ object.site.region|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -40,7 +40,7 @@
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -50,7 +50,7 @@
|
||||
{% if object.rack %}
|
||||
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -69,7 +69,7 @@
|
||||
{% elif object.rack and object.device_type.u_height %}
|
||||
<span class="badge bg-warning">Not racked</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -180,7 +180,7 @@
|
||||
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -195,7 +195,7 @@
|
||||
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
{% if object.vm_role %}
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -67,7 +67,7 @@
|
||||
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
{% if object.rf_channel_frequency %}
|
||||
{{ object.rf_channel_frequency|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if peer %}
|
||||
@@ -329,7 +329,7 @@
|
||||
{% if peer.rf_channel_frequency %}
|
||||
{{ peer.rf_channel_frequency|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
@@ -340,7 +340,7 @@
|
||||
{% if object.rf_channel_width %}
|
||||
{{ object.rf_channel_width|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if peer %}
|
||||
@@ -348,7 +348,7 @@
|
||||
{% if peer.rf_channel_width %}
|
||||
{{ peer.rf_channel_width|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{% if object.connected_endpoint %}
|
||||
{{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Power Port</th>
|
||||
<td>{{ object.power_port }}</td>
|
||||
<td>{{ object.power_port|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Feed Leg</th>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -115,7 +115,7 @@
|
||||
{% if object.type %}
|
||||
{{ object.get_type_display }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -133,7 +133,7 @@
|
||||
{% if object.outer_width %}
|
||||
<span>{{ object.outer_width }} {{ object.get_outer_unit_display }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -143,7 +143,7 @@
|
||||
{% if object.outer_depth %}
|
||||
<span>{{ object.outer_depth }} {{ object.get_outer_unit_display }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{% endfor %}
|
||||
{{ object.region|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -47,7 +47,7 @@
|
||||
{% endfor %}
|
||||
{{ object.group|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -79,7 +79,7 @@
|
||||
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
|
||||
<small class="text-muted">Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{% if device.rack %}
|
||||
{{ device.rack }} / {{ device.position }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ device.serial|placeholder }}</td>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
{% if object.choices %}
|
||||
{{ object.choices|join:", " }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -113,7 +113,7 @@
|
||||
{% if object.validation_regex %}
|
||||
<code>{{ object.validation_regex }}</code>
|
||||
{% else %}
|
||||
—
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
||||
|
||||
@@ -76,14 +76,14 @@ Context:
|
||||
{% if field.required %}
|
||||
{% checkmark True true="Required" %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.to_field_name %}
|
||||
<code>{{ field.to_field_name }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{% if object.role %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -73,7 +73,7 @@
|
||||
{% endif %}
|
||||
{{ object.assigned_object|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,7 +86,7 @@
|
||||
({{ object.nat_inside.assigned_object.parent_object|linkify }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{% if aggregate %}
|
||||
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
|
||||
{% else %}
|
||||
<span class="text-warning">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -52,7 +52,7 @@
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -65,7 +65,7 @@
|
||||
{% endif %}
|
||||
{{ object.vlan|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -138,7 +138,7 @@
|
||||
{{ first_available_ip }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{% if ipranges_count %}
|
||||
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
@@ -57,7 +57,7 @@
|
||||
{% if vlans_count %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{% for ipaddress in object.ipaddresses.all %}
|
||||
{{ ipaddress|linkify }}<br />
|
||||
{% empty %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -56,7 +56,7 @@
|
||||
{% if object.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{% if object.phone %}
|
||||
<a href="tel:{{ object.phone }}">{{ object.phone }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -45,7 +45,7 @@
|
||||
{% if object.email %}
|
||||
<a href="mailto:{{ object.email }}">{{ object.email }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% if request.user.first_name or request.user.last_name %}
|
||||
{{ request.user.first_name }} {{ request.user.last_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -64,7 +64,7 @@
|
||||
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -123,7 +123,7 @@
|
||||
{% if object.memory %}
|
||||
{{ object.memory|humanize_megabytes }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -133,7 +133,7 @@
|
||||
{% if object.disk %}
|
||||
{{ object.disk }} GB
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
{% if interface.rf_channel_frequency %}
|
||||
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -43,7 +43,7 @@
|
||||
{% if interface.rf_channel_width %}
|
||||
{{ interface.rf_channel_width|simplify_decimal }} MHz
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
||||
from django.templatetags.static import static
|
||||
from netaddr import AddrFormatError, EUI
|
||||
|
||||
from utilities.forms import widgets
|
||||
@@ -26,10 +27,9 @@ class CommentField(forms.CharField):
|
||||
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
|
||||
"""
|
||||
widget = forms.Textarea
|
||||
# TODO: Port Markdown cheat sheet to internal documentation
|
||||
help_text = """
|
||||
help_text = f"""
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">
|
||||
<a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">
|
||||
Markdown</a> syntax is supported
|
||||
"""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from markdown import markdown
|
||||
|
||||
from netbox.config import get_config
|
||||
from utilities.markdown import StrikethroughExtension
|
||||
from utilities.utils import foreground_color
|
||||
from utilities.utils import clean_html, foreground_color
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -144,18 +144,6 @@ def render_markdown(value):
|
||||
|
||||
{{ md_source_text|markdown }}
|
||||
"""
|
||||
schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
|
||||
|
||||
# Strip HTML tags
|
||||
value = strip_tags(value)
|
||||
|
||||
# Sanitize Markdown links
|
||||
pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)'
|
||||
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
|
||||
|
||||
# Sanitize Markdown reference links
|
||||
pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)'
|
||||
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
|
||||
|
||||
# Render Markdown
|
||||
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
|
||||
@@ -164,6 +152,11 @@ def render_markdown(value):
|
||||
if html:
|
||||
html = f'<div class="rendered-markdown">{html}</div>'
|
||||
|
||||
schemes = get_config().ALLOWED_URL_SCHEMES
|
||||
|
||||
# Sanitize HTML
|
||||
html = clean_html(html, schemes)
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from itertools import count, groupby
|
||||
|
||||
import bleach
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
@@ -14,6 +15,7 @@ from mptt.models import MPTTModel
|
||||
from dcim.choices import CableLengthUnitChoices
|
||||
from extras.plugins import PluginConfig
|
||||
from extras.utils import is_taggable
|
||||
from netbox.config import get_config
|
||||
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
|
||||
|
||||
|
||||
@@ -257,7 +259,9 @@ def render_jinja2(template_code, context):
|
||||
"""
|
||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||
"""
|
||||
return SandboxedEnvironment().from_string(source=template_code).render(**context)
|
||||
environment = SandboxedEnvironment()
|
||||
environment.filters.update(get_config().JINJA2_FILTERS)
|
||||
return environment.from_string(source=template_code).render(**context)
|
||||
|
||||
|
||||
def prepare_cloned_fields(instance):
|
||||
@@ -382,3 +386,33 @@ def copy_safe_request(request):
|
||||
'path': request.path,
|
||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||
})
|
||||
|
||||
|
||||
def clean_html(html, schemes):
|
||||
"""
|
||||
Sanitizes HTML based on a whitelist of allowed tags and attributes.
|
||||
Also takes a list of allowed URI schemes.
|
||||
"""
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
"div", "pre", "code", "blockquote", "del",
|
||||
"hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "p", "br",
|
||||
"strong", "em", "a", "b", "i", "img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"dl", "dt", "dd",
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
"div": ['class'],
|
||||
"h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"],
|
||||
"a": ["href", "title"],
|
||||
"img": ["src", "title", "alt"],
|
||||
}
|
||||
|
||||
return bleach.clean(
|
||||
html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=schemes
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from django import forms
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from ipam.models import VLAN, VRF
|
||||
from ipam.models import VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -202,13 +202,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False,
|
||||
widget=StaticSelect()
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -220,7 +233,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
fieldsets = (
|
||||
(None, ('mtu', 'enabled', 'vrf', 'description')),
|
||||
('Related Interfaces', ('parent', 'bridge')),
|
||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'parent', 'bridge', 'mtu', 'vrf', 'description',
|
||||
|
||||
@@ -323,7 +323,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'virtual_machine': forms.HiddenInput(),
|
||||
|
||||
Reference in New Issue
Block a user