Merge branch 'develop' into feature

This commit is contained in:
jeremystretch
2022-06-20 11:50:23 -04:00
57 changed files with 765 additions and 132 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,3 +36,8 @@ REDIS = {
}
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
LOGGING = {
'version': 1,
'disable_existing_loggers': True
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

View File

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

View File

@@ -1,2 +1,3 @@
export * from './objectDepth';
export * from './rackImages';
export * from './previousPkCheck';

View File

@@ -0,0 +1,6 @@
import { createState } from '../state';
export const previousPkCheckState = createState<{ element: Nullable<HTMLInputElement> }>(
{ element: null },
{ persist: false },
);

View File

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

View File

@@ -94,7 +94,7 @@
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -50,7 +50,7 @@
{% if object.portal_url %}
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -40,7 +40,7 @@
{% if object.color %}
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %}
<span class="text-muted">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -180,7 +180,7 @@
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -195,7 +195,7 @@
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -54,7 +54,7 @@
{% if object.vm_role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
{% else %}
&mdash;
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -55,7 +55,7 @@
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
</a>
{% else %}
<span class="text-muted">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -321,7 +321,7 @@
{% if object.rf_channel_frequency %}
{{ object.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</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">&mdash;</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">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
{% endif %}

View File

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

View File

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

View File

@@ -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">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -94,7 +94,7 @@
</div>
<span>{{ object.physical_address|linebreaksbr }}</span>
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -113,7 +113,7 @@
</div>
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -57,7 +57,7 @@
{% if device.rack %}
{{ device.rack }} / {{ device.position }}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
<td>{{ device.serial|placeholder }}</td>

View File

@@ -69,7 +69,7 @@
{% if object.choices %}
{{ object.choices|join:", " }}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -113,7 +113,7 @@
{% if object.validation_regex %}
<code>{{ object.validation_regex }}</code>
{% else %}
&mdash;
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -57,7 +57,7 @@
{% elif obj %}
{{ obj }}
{% else %}
<span class="muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
<td class="rendered-markdown">{{ message|markdown }}</td>

View File

@@ -76,14 +76,14 @@ Context:
{% if field.required %}
{% checkmark True true="Required" %}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
<td>
{% if field.to_field_name %}
<code>{{ field.to_field_name }}</code>
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
<td>

View File

@@ -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">&mdash;</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>

View File

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

View File

@@ -45,7 +45,7 @@
{% if ipranges_count %}
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
{% else %}
&mdash;
{{ ''|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 %}
&mdash;
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>

View File

@@ -44,7 +44,7 @@
{% for ipaddress in object.ipaddresses.all %}
{{ ipaddress|linkify }}<br />
{% empty %}
<span class="text-muted">None</span>
{{ ''|placeholder }}
{% endfor %}
</td>
</tr>

View File

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

View File

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

View File

@@ -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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -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">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -123,7 +123,7 @@
{% if object.memory %}
{{ object.memory|humanize_megabytes }}
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
@@ -133,7 +133,7 @@
{% if object.disk %}
{{ object.disk }} GB
{% else %}
<span class="text-muted">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -33,7 +33,7 @@
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</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">&mdash;</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

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

View File

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

View File

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

View File

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

View File

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