Merge pull request #7130 from netbox-community/develop

Release v3.0.1
This commit is contained in:
Jeremy Stretch 2021-09-01 15:10:17 -04:00 committed by GitHub
commit 593874b45f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 521 additions and 213 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.0
placeholder: v3.0.1
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.0.0
placeholder: v3.0.1
validations:
required: true
- type: dropdown

View File

@ -1,5 +1,36 @@
# NetBox v3.0
## v3.0.1 (2021-09-01)
### Bug Fixes
* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device
* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews
* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data
* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission
* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type
* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables
* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH`
* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table
* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field
* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab
* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests
* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects
* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field
* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select
---
## v3.0.0 (2021-08-30)
!!! warning "Existing Deployments Must Upgrade from v2.11"

View File

@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
continue
try:
response[method] = getattr(d, method)()
response[method] = decode_dict(getattr(d, method)())
except NotImplementedError:
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:

View File

@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data['tagged_vlans']
tagged_vlans = self.cleaned_data.get('tagged_vlans')
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form):
self.cleaned_data['tagged_vlans'] = []
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
@ -4586,8 +4586,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
color = ColorField(
required=False
)
length = forms.IntegerField(
min_value=1,
length = forms.DecimalField(
min_value=0,
required=False
)
length_unit = forms.ChoiceField(

View File

@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
CustomFieldTypeChoices.TYPE_DATE,
CustomFieldTypeChoices.TYPE_INTEGER,
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT,
)
@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
self.field_name = f'custom_field_data__{self.field_name}'
if custom_field.type not in EXACT_FILTER_TYPES:
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
self.lookup_expr = 'has_key'
elif custom_field.type not in EXACT_FILTER_TYPES:
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
self.lookup_expr = 'icontains'

View File

@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
#
class ContentTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = ContentType
fields = ['id', 'app_label', 'model']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)

View File

@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
cf.content_types.set([obj_type])
# Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
cf.save()
cf.content_types.set([obj_type])
# Multiselect filtering
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
cf.save()
cf.content_types.set([obj_type])
@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
'cf6': 'http://foo.example.com/',
'cf7': 'http://foo.example.com/',
'cf8': 'Foo',
'cf9': ['A', 'B'],
}),
Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200,
@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
'cf6': 'http://bar.example.com/',
'cf7': 'http://bar.example.com/',
'cf8': 'Bar',
'cf9': ['AA', 'B'],
}),
Site(name='Site 3', slug='site-3', custom_field_data={
}),
Site(name='Site 3', slug='site-3'),
])
def test_filter_integer(self):
@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)

View File

@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
children = MultiValueNumberFilter(
field_name='_children'
)
mask_length = django_filters.NumberFilter(
mask_length = MultiValueNumberFilter(
field_name='prefix',
lookup_expr='net_mask_length'
)

View File

@ -491,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'status': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class PrefixCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@ -658,11 +653,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
label=_('Address family'),
widget=StaticSelect()
)
mask_length = forms.ChoiceField(
mask_length = forms.MultipleChoiceField(
required=False,
choices=PREFIX_MASK_LENGTH_CHOICES,
label=_('Mask length'),
widget=StaticSelect()
widget=StaticSelectMultiple()
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@ -760,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'status': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPRangeCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@ -1026,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent_object
@ -1102,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'role': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPAddressCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@ -1256,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
empty_label='Global'
label='VRF'
)
q = forms.CharField(
required=False,

View File

@ -825,9 +825,9 @@ class IPAddress(PrimaryModel):
if self.pk:
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if parent and getattr(self.assigned_object, attr) != parent:
if parent and getattr(self.assigned_object, attr, None) != parent:
# Check for a NAT relationship
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent:
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
raise ValidationError({
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
f"not assigned to it!"

View File

@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self):
params = {'mask_length': '24'}
params = {'mask_length': ['24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vrf(self):

View File

@ -403,13 +403,19 @@ class PrefixPrefixesView(generic.ObjectView):
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return {
'first_available_prefix': instance.get_first_available_prefix(),
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'prefixes',
'first_available_prefix': instance.get_first_available_prefix(),
'show_available': request.GET.get('show_available', 'true') == 'true',
'table_config_form': TableConfigForm(table=table),
}
@ -421,15 +427,22 @@ class PrefixIPRangesView(generic.ObjectView):
# Find all IPRanges belonging to this Prefix
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
table = tables.IPRangeTable(ip_ranges)
table = tables.IPRangeTable(ip_ranges, user=request.user)
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_iprange'),
'delete': request.user.has_perm('ipam.delete_iprange'),
}
return {
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'ip-ranges',
}
@ -449,18 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView):
if request.GET.get('show_available', 'true') == 'true':
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
table = tables.IPAddressTable(ipaddresses)
table = tables.IPAddressTable(ipaddresses, user=request.user)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return {
'first_available_ip': instance.get_first_available_ip(),
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'ip-addresses',
'first_available_ip': instance.get_first_available_ip(),
'show_available': request.GET.get('show_available', 'true') == 'true',
}

View File

@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:])
def get_limit(self, request):
limit = super().get_limit(request)
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
# Enforce maximum page size
if settings.MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE)
return self.default_limit
return limit
def get_next_link(self):

View File

@ -113,6 +113,10 @@ class ExceptionHandlingMiddleware(object):
def process_exception(self, request, exception):
# Handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
# Don't catch exceptions when in debug mode
if settings.DEBUG:
return
@ -121,10 +125,6 @@ class ExceptionHandlingMiddleware(object):
if isinstance(exception, Http404):
return
# Handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
# Determine the type of exception. If it's a common issue, return a custom error page with instructions.
custom_template = None
if isinstance(exception, ProgrammingError):

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.0'
VERSION = '3.0.1'
# Hostname
HOSTNAME = platform.node()
@ -560,6 +560,10 @@ RQ_QUEUES = {
#
# Pagination
if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
raise ImproperlyConfigured(
f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
)
PER_PAGE_DEFAULTS = [
25, 50, 100, 250, 500, 1000
]

View File

@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
'table': table,
'permissions': permissions,
'action_buttons': self.action_buttons,
'table_config_form': TableConfigForm(table=table),
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
}
context.update(self.extra_context())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
import { Collapse, Modal, Tab, Toast, Tooltip } from 'bootstrap';
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
import Masonry from 'masonry-layout';
import { getElements } from './util';
import { createElement, getElements } from './util';
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
// plugins).
window.Collapse = Collapse;
window.Modal = Modal;
window.Popover = Popover;
window.Toast = Toast;
window.Tooltip = Tooltip;
@ -156,13 +157,48 @@ function initSidebarAccordions(): void {
}
}
/**
* Initialize image preview popover, which shows a preview of an image from an image link with the
* `.image-preview` class.
*/
function initImagePreview(): void {
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
// Generate a max-width that's a quarter of the screen's width (note - the actual element
// width will be slightly larger due to the popover body's padding).
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
// Create an image element that uses the linked image as its `src`.
const image = createElement('img', { src: element.href });
image.style.maxWidth = maxWidth;
// Create a container for the image.
const content = createElement('div', null, null, [image]);
// Initialize the Bootstrap Popper instance.
new Popover(element, {
// Attach this custom class to the popover so that it styling can be controlled via CSS.
customClass: 'image-preview-popover',
trigger: 'hover',
html: true,
content,
});
}
}
/**
* Enable any defined Bootstrap Tooltips.
*
* @see https://getbootstrap.com/docs/5.0/components/tooltips
*/
export function initBootstrap(): void {
for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) {
for (const func of [
initTooltips,
initModals,
initMasonry,
initTabs,
initImagePreview,
initSidebarAccordions,
]) {
func();
}
}

View File

@ -13,18 +13,26 @@ function initConfig(): void {
.then(data => {
if (hasError(data)) {
createToast('danger', 'Error Fetching Device Config', data.error).show();
console.error(data.error);
return;
} else if (hasError<Required<DeviceConfig['get_config']>>(data.get_config)) {
createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
console.error(data.get_config.error);
return;
} else {
const configTypes = [
'running',
'startup',
'candidate',
] as (keyof DeviceConfig['get_config'])[];
const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
for (const configType of configTypes) {
const element = document.getElementById(`${configType}_config`);
if (element !== null) {
element.innerHTML = data.get_config[configType];
const config = data.get_config[configType];
if (typeof config === 'string') {
// If the returned config is a string, set the element innerHTML as-is.
element.innerHTML = config;
} else {
// If the returned config is an object (dict), convert it to JSON.
element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
}
}
}
}

View File

@ -1,4 +1,4 @@
import { all, getElement, resetSelect, toggleVisibility } from '../util';
import { all, getElement, resetSelect, toggleVisibility as _toggleVisibility } from '../util';
/**
* Get a select element's containing `.row` element.
@ -14,6 +14,38 @@ function fieldContainer(element: Nullable<HTMLSelectElement>): Nullable<HTMLElem
return null;
}
/**
* Toggle visibility of the select element's container and disable the select element itself.
*
* @param element Select element.
* @param action 'show' or 'hide'
*/
function toggleVisibility<E extends Nullable<HTMLSelectElement>>(
element: E,
action: 'show' | 'hide',
): void {
// Find the select element's containing element.
const parent = fieldContainer(element);
if (element !== null && parent !== null) {
// Toggle container visibility to visually remove it from the form.
_toggleVisibility(parent, action);
// Create a new event so that the APISelect instance properly handles the enable/disable
// action.
const event = new Event(`netbox.select.disabled.${element.name}`);
switch (action) {
case 'hide':
// Disable the native select element and dispatch the event APISelect is listening for.
element.disabled = true;
element.dispatchEvent(event);
break;
case 'show':
// Enable the native select element and dispatch the event APISelect is listening for.
element.disabled = false;
element.dispatchEvent(event);
}
}
}
/**
* Toggle element visibility when the mode field does not have a value.
*/
@ -29,7 +61,7 @@ function handleModeNone(): void {
resetSelect(untaggedVlan);
resetSelect(taggedVlans);
for (const element of elements) {
toggleVisibility(fieldContainer(element), 'hide');
toggleVisibility(element, 'hide');
}
}
}
@ -46,9 +78,9 @@ function handleModeAccess(): void {
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
resetSelect(taggedVlans);
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(fieldContainer(taggedVlans), 'hide');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
toggleVisibility(taggedVlans, 'hide');
}
}
@ -63,9 +95,9 @@ function handleModeTagged(): void {
];
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
toggleVisibility(fieldContainer(taggedVlans), 'show');
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(taggedVlans, 'show');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
}
}
@ -81,9 +113,9 @@ function handleModeTaggedAll(): void {
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
resetSelect(taggedVlans);
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(fieldContainer(taggedVlans), 'hide');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
toggleVisibility(taggedVlans, 'hide');
}
}

View File

@ -17,6 +17,11 @@ interface Window {
*/
Modal: typeof import('bootstrap').Modal;
/**
* Bootstrap Popover Instance.
*/
Popover: typeof import('bootstrap').Popover;
/**
* Bootstrap Toast Instance.
*/
@ -147,12 +152,15 @@ type LLDPNeighborDetail = {
type DeviceConfig = {
get_config: {
candidate: string;
running: string;
startup: string;
candidate: string | Record<string, unknown>;
running: string | Record<string, unknown>;
startup: string | Record<string, unknown>;
error?: string;
};
};
type DeviceConfigType = Exclude<keyof DeviceConfig['get_config'], 'error'>;
type DeviceEnvironment = {
cpu?: {
[core: string]: { '%usage': number };

View File

@ -320,6 +320,7 @@ export class APISelect {
this.slim.slim.multiSelected.container.setAttribute('disabled', '');
}
}
this.slim.disable();
}
/**
@ -335,6 +336,7 @@ export class APISelect {
this.slim.slim.multiSelected.container.removeAttribute('disabled');
}
}
this.slim.enable();
}
/**
@ -357,6 +359,11 @@ export class APISelect {
this.fetchOptions(this.more, 'merge'),
);
// When the base select element is disabled or enabled, properly disable/enable this instance.
this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
this.handleDisableEnable(event),
);
// Create a unique iterator of all possible form fields which, when changed, should cause this
// element to update its API query.
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
@ -389,6 +396,19 @@ export class APISelect {
}
}
/**
* Get all options from the native select element that are already selected and do not contain
* placeholder values.
*/
private getPreselectedOptions(): HTMLOptionElement[] {
return Array.from(this.base.options)
.filter(option => option.selected)
.filter(option => {
if (option.value === '---------' || option.innerText === '---------') return false;
return true;
});
}
/**
* Process a valid API response and add results to this instance's options.
*
@ -398,13 +418,19 @@ export class APISelect {
data: APIAnswer<APIObjectBase>,
action: ApplyMethod = 'merge',
): Promise<void> {
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
// existing object. When we fetch options from the API later, we can set any of the options
// contained in this array to `selected`.
const selectOptions = Array.from(this.base.options)
.filter(option => option.selected)
.map(option => option.getAttribute('value'))
.filter(isTruthy);
// Get all already-selected options.
const preSelected = this.getPreselectedOptions();
// Get the values of all already-selected options.
const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
// Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({
value: option.value,
text: option.innerText,
selected: true,
disabled: false,
})) as Option[];
let options = [] as Option[];
@ -441,12 +467,12 @@ export class APISelect {
}
// Set option to disabled if it is contained within the disabled array.
if (selectOptions.some(option => this.disabledOptions.includes(option))) {
if (selectedValues.some(option => this.disabledOptions.includes(option))) {
disabled = true;
}
// Set pre-selected options.
if (selectOptions.includes(value)) {
if (selectedValues.includes(value)) {
selected = true;
// If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
// the rest of the form, resulting in that field's value being deleting from the object.
@ -469,7 +495,8 @@ export class APISelect {
this.options = [...this.options, ...options];
break;
case 'replace':
this.options = options;
this.options = [...preSelectedOptions, ...options];
break;
}
if (hasMore(data)) {
@ -558,6 +585,23 @@ export class APISelect {
Promise.all([this.loadData()]);
}
/**
* Event handler to be dispatched when the base select element is disabled or enabled. When that
* occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
* desired action.
*
* @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
*/
private handleDisableEnable(event: Event): void {
const target = event.target as HTMLSelectElement;
if (target.disabled === true) {
this.disable();
} else if (target.disabled === false) {
this.enable();
}
}
/**
* When the API returns an error, show it to the user and reset this element's available options.
*
@ -715,7 +759,7 @@ export class APISelect {
private getPlaceholder(): string {
let placeholder = this.name;
if (this.base.id) {
const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement;
const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
// Set the placeholder text to the label value, if it exists.
if (label !== null) {
placeholder = `Select ${label.innerText.trim()}`;

View File

@ -4,7 +4,7 @@ import { getElements } from '../util';
export function initStaticSelect(): void {
for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
if (select !== null) {
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
let placeholder;
if (label !== null) {

View File

@ -1,4 +1,5 @@
import Cookie from 'cookie';
import queryString from 'query-string';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
@ -11,14 +12,16 @@ type InferredProps<
// Element name.
T extends keyof HTMLElementTagNameMap,
// Element type.
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T]
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
> = Partial<Record<keyof E, E[keyof E]>>;
export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data;
}
export function hasError(data: Record<string, unknown>): data is ErrorBase {
export function hasError<E extends ErrorBase = ErrorBase>(
data: Record<string, unknown>,
): data is E {
return 'error' in data;
}
@ -94,7 +97,7 @@ export function isElement(obj: Element | null | undefined): obj is Element {
/**
* Retrieve the CSRF token from cookie storage.
*/
export function getCsrfToken(): string {
function getCsrfToken(): string {
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
if (typeof csrfToken === 'undefined') {
throw new Error('Invalid or missing CSRF token');
@ -102,8 +105,60 @@ export function getCsrfToken(): string {
return csrfToken;
}
/**
* Get the NetBox `settings.BASE_PATH` from the `<html/>` element's data attributes.
*
* @returns If there is no `BASE_PATH` specified, the return value will be `''`.
*/ function getBasePath(): string {
const value = document.documentElement.getAttribute('data-netbox-base-path');
if (value === null) {
return '';
}
return value;
}
/**
* Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes.
*
* @example
* ```js
* // With a BASE_PATH of 'netbox/'
* const url = buildUrl('/api/dcim/devices');
* console.log(url);
* // => /netbox/api/dcim/devices/
* ```
*
* @param path Relative path _after_ (excluding) the `BASE_PATH`.
*/
function buildUrl(destination: string): string {
// Separate the path from any URL search params.
const [pathname, search] = destination.split(/(?=\?)/g);
// If the `origin` exists in the API path (as in the case of paginated responses), remove it.
const origin = new RegExp(window.location.origin, 'g');
const path = pathname.replaceAll(origin, '');
const basePath = getBasePath();
// Combine `BASE_PATH` with this request's path, removing _all_ slashes.
let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p);
if (combined[0] !== '/') {
// Ensure the URL has a leading slash.
combined = ['', ...combined];
}
if (combined[combined.length - 1] !== '/') {
// Ensure the URL has a trailing slash.
combined = [...combined, ''];
}
const url = combined.join('/');
// Construct an object from the URL search params so it can be re-serialized with the new URL.
const query = Object.fromEntries(new URLSearchParams(search).entries());
return queryString.stringifyUrl({ url, query });
}
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
url: string,
path: string,
method: Method,
data?: D,
): Promise<APIResponse<R>> {
@ -115,6 +170,7 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
body = JSON.stringify(data);
headers.set('content-type', 'application/json');
}
const url = buildUrl(path);
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
const contentType = res.headers.get('Content-Type');
@ -367,8 +423,13 @@ export function createElement<
// Element props.
P extends InferredProps<T>,
// Child element type.
C extends HTMLElement = HTMLElement
>(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] {
C extends HTMLElement = HTMLElement,
>(
tag: T,
properties: P | null,
classes: Nullable<string[]> = null,
children: C[] = [],
): HTMLElementTagNameMap[T] {
// Create the base element.
const element = document.createElement<T>(tag);
@ -384,7 +445,9 @@ export function createElement<
}
// Add each CSS class to the element's class list.
element.classList.add(...classes);
if (classes !== null && classes.length > 0) {
element.classList.add(...classes);
}
for (const child of children) {
// Add each child element to the base element.

View File

@ -956,6 +956,11 @@ div.card-overlay {
}
}
// Remove the max-width from image preview popovers as this is controlled on the image element.
.popover.image-preview-popover {
max-width: unset;
}
#django-messages {
position: fixed;
right: $spacer;

View File

@ -4,7 +4,7 @@
<head>
<title>Server Error</title>
<link rel="stylesheet" href="{% static 'netbox.css'%}" />
<link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
<meta charset="UTF-8">
</head>
@ -12,7 +12,7 @@
<div class="container-fluid">
<div class="row">
<div class="col col-md-6 offset-md-3">
<div class="card bg-danger mt-5">
<div class="card border-danger mt-5">
<h5 class="card-header">
<i class="mdi mdi-alert"></i> Server Error
</h5>
@ -32,7 +32,7 @@
Python version: {{ python_version }}
NetBox version: {{ netbox_version }}</pre>
<p>
If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub.
</p>
<div class="text-end">
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

View File

@ -5,6 +5,7 @@
<html
lang="en"
data-netbox-path="{{ request.path }}"
data-netbox-base-path="{{ settings.BASE_PATH }}"
{% if preferences|get_key:'ui.colormode' == 'dark'%}
data-netbox-color-mode="dark"
{% else %}

View File

@ -7,12 +7,12 @@
{# Brand #}
{# Full Logo #}
<a class="sidenav-brand" href="/">
<a class="sidenav-brand" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
</a>
{# Icon Logo #}
<a class="sidenav-brand-icon" href="/">
<a class="sidenav-brand-icon" href="{% url 'home' %}">
<img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="NetBox Logo">
</a>

View File

@ -109,8 +109,8 @@
<td>
{% if object.physical_address %}
<div class="float-end noprint">
<a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map it
<a href="{{ settings.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
<span>{{ object.physical_address|linebreaksbr }}</span>
@ -129,7 +129,7 @@
{% if object.latitude and object.longitude %}
<div class="float-end noprint">
<a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map it
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
<span>{{ object.latitude }}, {{ object.longitude }}</span>

View File

@ -9,7 +9,7 @@
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
{% endblock %}
{% block content %}
{% block form %}
<form action="{% querystring request %}" method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
@ -17,13 +17,10 @@
{% endfor %}
<div class="row mb-3">
<div class="col col-md-8 offset-md-2">
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
<div class="card">
<h5 class="card-header">Select IP Address</h5>
<div class="card-body">
{% render_field form.vrf_id %}
{% render_field form.q %}
</div>
<div class="field-group">
<h6>Select IP Address</h6>
{% render_field form.vrf_id %}
{% render_field form.q %}
</div>
</div>
</div>
@ -42,4 +39,7 @@
</div>
</div>
{% endif %}
{% endblock %}
{% endblock form %}
{% block buttons %}
{% endblock buttons%}

View File

@ -1,8 +1,10 @@
{% extends 'ipam/prefix/base.html' %}
{% load helpers %}
{% load static %}
{% block extra_controls %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-primary">
{% if perms.ipam.add_ipaddress and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
</a>
{% endif %}
@ -11,7 +13,9 @@
{% block content %}
<div class="row">
<div class="col col-md-12">
{% include 'inc/table_controls.html' with table_modal="IPAddressTable_config" %}
{% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
</div>
</div>
{% table_config_form table table_name="IPAddressTable" %}
{% endblock %}

View File

@ -1,10 +1,13 @@
{% extends 'ipam/prefix/base.html' %}
{% load helpers %}
{% load static %}
{% block content %}
<div class="row">
<div class="col col-md-12">
{% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
{% include 'inc/table_controls.html' with table_modal="IPRangeTable_config" %}
{% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:iprange_bulk_edit' bulk_delete_url='ipam:iprange_bulk_delete' parent=prefix %}
</div>
</div>
{% table_config_form table table_name="IPRangeTable" %}
{% endblock %}

View File

@ -2,20 +2,11 @@
{% load helpers %}
{% load static %}
{% block buttons %}
{% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %}
{% if request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
{% endif %}
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
</a>
{% endif %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Add an IP Address
{% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
</a>
{% endif %}
{{ block.super }}
@ -24,12 +15,9 @@
{% block content %}
<div class="row">
<div class="col col-md-12">
{% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %}
{% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
</div>
</div>
{% table_config_form prefix_table table_name="PrefixDetailTable" %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/tableconfig.js' %}"></script>
{% table_config_form table table_name="PrefixDetailTable" %}
{% endblock %}

View File

@ -42,7 +42,7 @@
The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP process.
</li>
</ul>
<p>Click <a href="/">here</a> to attempt loading NetBox again.</p>
<p>Click <a href="{% url 'home' %}">here</a> to attempt loading NetBox again.</p>
</div>
</body>
</html>

View File

@ -9,5 +9,5 @@
{% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %}
{% block branding %}
<a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
<a class="navbar-brand" href="{% url 'home' %}">NetBox</a>
{% endblock branding %}

View File

@ -1,40 +1,44 @@
{% load helpers %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint">
<div class="card-body">
<div class="float-end">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
</div>
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
<div class="float-end">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
</div>
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
</div>
{% endif %}
{% include table_template|default:'inc/responsive_table.html' %}
<div class="float-start noprint">
{% block extra_actions %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
@ -43,7 +47,9 @@
</div>
</form>
{% else %}
{% include table_template|default:'inc/responsive_table.html' %}
{% endif %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}

View File

@ -7,11 +7,11 @@
<h5 class="modal-title">Table Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form class="form-horizontal userconfigform" data-config-root="tables.{{ table_config_form.table_name }}">
<form class="form-horizontal userconfigform" data-config-root="tables.{{ form.table_name }}">
<div class="modal-body row">
<div class="col-5 text-center">
{{ table_config_form.available_columns.label }}
{{ table_config_form.available_columns }}
{{ form.available_columns.label }}
{{ form.available_columns }}
</div>
<div class="col-2 d-flex align-items-center">
<div>
@ -24,8 +24,8 @@
</div>
</div>
<div class="col-5 text-center">
{{ table_config_form.columns.label }}
{{ table_config_form.columns }}
{{ form.columns.label }}
{{ form.columns }}
<a class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
<i class="mdi mdi-arrow-up-bold"></i> Move Up
</a>

View File

@ -131,7 +131,7 @@
<th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
<td>
{% if object.memory %}
{{ object.memory|humanize_megabytes }} MB
{{ object.memory|humanize_megabytes }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}

View File

@ -48,7 +48,8 @@ def is_api_request(request):
Return True of the request is being made via the REST API.
"""
api_path = reverse('api-root')
return request.path_info.startswith(api_path)
return request.path_info.startswith(api_path) and request.content_type == 'application/json'
def get_view_name(view, suffix=None):

View File

@ -435,7 +435,7 @@ class DynamicModelChoiceMixin:
filter = self.filter(field_name=field_name)
try:
self.queryset = filter.filter(self.queryset, data)
except TypeError:
except (TypeError, ValueError):
# Catch any error caused by invalid initial data passed from the user
self.queryset = self.queryset.none()
else:

View File

@ -119,13 +119,14 @@ def get_selected_values(form, field_name):
"""
if not hasattr(form, 'cleaned_data'):
form.is_valid()
filter_data = form.cleaned_data.get(field_name)
# Selection field
if hasattr(form.fields[field_name], 'choices'):
try:
choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
return [
label for value, label in choices.items() if value in form.cleaned_data[field_name]
label for value, label in choices.items() if str(value) in filter_data
]
except TypeError:
# Field uses dynamic choices. Show all that have been populated.
@ -134,7 +135,7 @@ def get_selected_values(form, field_name):
]
# Non-selection field
return [str(form.cleaned_data[field_name])]
return [str(filter_data)]
def add_blank_choice(choices):

View File

@ -185,7 +185,7 @@ class APISelect(SelectWithDisabled):
# layer.
if key in self.static_params:
current = self.static_params[key]
self.static_params[key] = [*current, value]
self.static_params[key] = [v for v in set([*current, value])]
else:
self.static_params[key] = [value]
else:
@ -194,7 +194,7 @@ class APISelect(SelectWithDisabled):
# `$`).
if key in self.static_params:
current = self.static_params[key]
self.static_params[key] = [*current, value]
self.static_params[key] = [v for v in set([*current, value])]
else:
self.static_params[key] = [value]

View File

@ -21,8 +21,8 @@ class Command(_Command):
raise CommandError(
"This command is available for development purposes only. It will\n"
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
"please post to the NetBox mailing list:\n"
" https://groups.google.com/g/netbox-discuss"
"please post to the NetBox discussion forum on GitHub:\n"
" https://github.com/netbox-community/netbox/discussions"
)
super().handle(*args, **kwargs)

View File

@ -49,21 +49,25 @@ class EnhancedPage(Page):
def get_paginate_count(request):
"""
Determine the length of a page, using the following in order:
Determine the desired length of a page, using the following in order:
1. per_page URL query parameter
2. Saved user preference
3. PAGINATE_COUNT global setting.
Return the lesser of the calculated value and MAX_PAGE_SIZE.
"""
if 'per_page' in request.GET:
try:
per_page = int(request.GET.get('per_page'))
if request.user.is_authenticated:
request.user.config.set('pagination.per_page', per_page, commit=True)
return per_page
return min(per_page, settings.MAX_PAGE_SIZE)
except ValueError:
pass
if request.user.is_authenticated:
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
return settings.PAGINATE_COUNT
per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
return min(per_page, settings.MAX_PAGE_SIZE)
return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)

View File

@ -237,9 +237,13 @@ class ContentTypeColumn(tables.Column):
Display a ContentType instance.
"""
def render(self, value):
if value is None:
return None
return content_type_name(value)
def value(self, value):
if value is None:
return None
return f"{value.app_label}.{value.model}"

View File

@ -304,28 +304,6 @@ def get_item(value: object, attr: str) -> Any:
return value[attr]
#
# Tags
#
@register.simple_tag()
def querystring(request, **kwargs):
"""
Append or update the page number in a querystring.
"""
querydict = request.GET.copy()
for k, v in kwargs.items():
if v is not None:
querydict[k] = str(v)
elif k in querydict:
querydict.pop(k)
querystring = querydict.urlencode(safe='/')
if querystring:
return '?' + querystring
else:
return ''
@register.filter
def status_from_tag(tag: str = "info") -> str:
"""
@ -355,6 +333,28 @@ def icon_from_status(status: str = "info") -> str:
return icon_map.get(status.lower(), 'information')
#
# Tags
#
@register.simple_tag()
def querystring(request, **kwargs):
"""
Append or update the page number in a querystring.
"""
querydict = request.GET.copy()
for k, v in kwargs.items():
if v is not None:
querydict[k] = str(v)
elif k in querydict:
querydict.pop(k)
querystring = querydict.urlencode(safe='/')
if querystring:
return '?' + querystring
else:
return ''
@register.inclusion_tag('utilities/templatetags/utilization_graph.html')
def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
"""
@ -401,7 +401,7 @@ def badge(value, bg_class='secondary', show_empty=False):
def table_config_form(table, table_name=None):
return {
'table_name': table_name or table.__class__.__name__,
'table_config_form': TableConfigForm(table=table),
'form': TableConfigForm(table=table),
}
@ -411,16 +411,16 @@ def applied_filters(form, query_params):
Display the active filters for a given filter form.
"""
form.is_valid()
querydict = query_params.copy()
applied_filters = []
for filter_name in form.changed_data:
if filter_name not in query_params:
if filter_name not in querydict:
continue
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
querydict = query_params.copy()
querydict.pop(filter_name)
display_value = ', '.join(get_selected_values(form, filter_name))
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
applied_filters.append({
'name': filter_name,

View File

@ -1,7 +1,9 @@
import datetime
import json
import urllib
from collections import OrderedDict
from itertools import count, groupby
from typing import Any, Dict, List, Tuple
from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery
@ -286,6 +288,45 @@ 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()}
# Taken from django.utils.functional (<3.0)
def curry(_curried_func, *args, **kwargs):
def _curried(*moreargs, **morekwargs):
@ -307,8 +348,12 @@ def content_type_name(contenttype):
"""
Return a proper ContentType name.
"""
meta = contenttype.model_class()._meta
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
try:
meta = contenttype.model_class()._meta
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
except AttributeError:
# Model no longer exists
return f'{contenttype.app_label} > {contenttype.model}'
#

View File

@ -1,4 +1,4 @@
Django==3.2.6
Django==3.2.7
django-cors-headers==3.8.0
django-debug-toolbar==3.2.2
django-filter==2.4.0