mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 20:22:53 -06:00
commit
593874b45f
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -17,7 +17,7 @@ body:
|
|||||||
What version of NetBox are you currently running? (If you don't have access to the most
|
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/)
|
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.)
|
before opening a bug report to see if your issue has already been addressed.)
|
||||||
placeholder: v3.0.0
|
placeholder: v3.0.1
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.0.0
|
placeholder: v3.0.1
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -1,5 +1,36 @@
|
|||||||
# NetBox v3.0
|
# 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)
|
## v3.0.0 (2021-08-30)
|
||||||
|
|
||||||
!!! warning "Existing Deployments Must Upgrade from v2.11"
|
!!! warning "Existing Deployments Must Upgrade from v2.11"
|
||||||
|
@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
|||||||
from netbox.api.exceptions import ServiceUnavailable
|
from netbox.api.exceptions import ServiceUnavailable
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from utilities.api import get_serializer_for_model
|
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 virtualization.models import VirtualMachine
|
||||||
from . import serializers
|
from . import serializers
|
||||||
from .exceptions import MissingFilterException
|
from .exceptions import MissingFilterException
|
||||||
@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
|||||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
response[method] = getattr(d, method)()
|
response[method] = decode_dict(getattr(d, method)())
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
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
|
# Untagged interfaces cannot be assigned tagged VLANs
|
||||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and 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'] = []
|
self.cleaned_data['tagged_vlans'] = []
|
||||||
|
|
||||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
# 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]
|
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]
|
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(
|
color = ColorField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
length = forms.IntegerField(
|
length = forms.DecimalField(
|
||||||
min_value=1,
|
min_value=0,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
length_unit = forms.ChoiceField(
|
length_unit = forms.ChoiceField(
|
||||||
|
@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
|
|||||||
CustomFieldTypeChoices.TYPE_DATE,
|
CustomFieldTypeChoices.TYPE_DATE,
|
||||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
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}'
|
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:
|
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||||
self.lookup_expr = 'icontains'
|
self.lookup_expr = 'icontains'
|
||||||
|
|
||||||
|
@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ContentTypeFilterSet(django_filters.FilterSet):
|
class ContentTypeFilterSet(django_filters.FilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContentType
|
model = ContentType
|
||||||
fields = ['id', 'app_label', 'model']
|
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)
|
||||||
|
)
|
||||||
|
@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Selection filtering
|
# 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.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
'cf6': 'http://foo.example.com/',
|
'cf6': 'http://foo.example.com/',
|
||||||
'cf7': 'http://foo.example.com/',
|
'cf7': 'http://foo.example.com/',
|
||||||
'cf8': 'Foo',
|
'cf8': 'Foo',
|
||||||
|
'cf9': ['A', 'B'],
|
||||||
}),
|
}),
|
||||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||||
'cf1': 200,
|
'cf1': 200,
|
||||||
@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
'cf6': 'http://bar.example.com/',
|
'cf6': 'http://bar.example.com/',
|
||||||
'cf7': 'http://bar.example.com/',
|
'cf7': 'http://bar.example.com/',
|
||||||
'cf8': 'Bar',
|
'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):
|
def test_filter_integer(self):
|
||||||
@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
|
|
||||||
def test_filter_select(self):
|
def test_filter_select(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
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)
|
||||||
|
@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
|||||||
children = MultiValueNumberFilter(
|
children = MultiValueNumberFilter(
|
||||||
field_name='_children'
|
field_name='_children'
|
||||||
)
|
)
|
||||||
mask_length = django_filters.NumberFilter(
|
mask_length = MultiValueNumberFilter(
|
||||||
field_name='prefix',
|
field_name='prefix',
|
||||||
lookup_expr='net_mask_length'
|
lookup_expr='net_mask_length'
|
||||||
)
|
)
|
||||||
|
@ -491,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
'status': StaticSelect(),
|
'status': StaticSelect(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.fields['vrf'].empty_label = 'Global'
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixCSVForm(CustomFieldModelCSVForm):
|
class PrefixCSVForm(CustomFieldModelCSVForm):
|
||||||
vrf = CSVModelChoiceField(
|
vrf = CSVModelChoiceField(
|
||||||
@ -658,11 +653,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
|||||||
label=_('Address family'),
|
label=_('Address family'),
|
||||||
widget=StaticSelect()
|
widget=StaticSelect()
|
||||||
)
|
)
|
||||||
mask_length = forms.ChoiceField(
|
mask_length = forms.MultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=PREFIX_MASK_LENGTH_CHOICES,
|
choices=PREFIX_MASK_LENGTH_CHOICES,
|
||||||
label=_('Mask length'),
|
label=_('Mask length'),
|
||||||
widget=StaticSelect()
|
widget=StaticSelectMultiple()
|
||||||
)
|
)
|
||||||
vrf_id = DynamicModelMultipleChoiceField(
|
vrf_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -760,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
'status': StaticSelect(),
|
'status': StaticSelect(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.fields['vrf'].empty_label = 'Global'
|
|
||||||
|
|
||||||
|
|
||||||
class IPRangeCSVForm(CustomFieldModelCSVForm):
|
class IPRangeCSVForm(CustomFieldModelCSVForm):
|
||||||
vrf = CSVModelChoiceField(
|
vrf = CSVModelChoiceField(
|
||||||
@ -1026,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.fields['vrf'].empty_label = 'Global'
|
|
||||||
|
|
||||||
# Initialize primary_for_parent if IP address is already assigned
|
# Initialize primary_for_parent if IP address is already assigned
|
||||||
if self.instance.pk and self.instance.assigned_object:
|
if self.instance.pk and self.instance.assigned_object:
|
||||||
parent = self.instance.assigned_object.parent_object
|
parent = self.instance.assigned_object.parent_object
|
||||||
@ -1102,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
'role': StaticSelect(),
|
'role': StaticSelect(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields['vrf'].empty_label = 'Global'
|
|
||||||
|
|
||||||
|
|
||||||
class IPAddressCSVForm(CustomFieldModelCSVForm):
|
class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||||
vrf = CSVModelChoiceField(
|
vrf = CSVModelChoiceField(
|
||||||
@ -1256,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
|||||||
vrf_id = DynamicModelChoiceField(
|
vrf_id = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='VRF',
|
label='VRF'
|
||||||
empty_label='Global'
|
|
||||||
)
|
)
|
||||||
q = forms.CharField(
|
q = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -825,9 +825,9 @@ class IPAddress(PrimaryModel):
|
|||||||
if self.pk:
|
if self.pk:
|
||||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
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
|
# 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({
|
raise ValidationError({
|
||||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||||
f"not assigned to it!"
|
f"not assigned to it!"
|
||||||
|
@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_mask_length(self):
|
def test_mask_length(self):
|
||||||
params = {'mask_length': '24'}
|
params = {'mask_length': ['24']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
def test_vrf(self):
|
def test_vrf(self):
|
||||||
|
@ -403,13 +403,19 @@ class PrefixPrefixesView(generic.ObjectView):
|
|||||||
|
|
||||||
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
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 {
|
return {
|
||||||
'first_available_prefix': instance.get_first_available_prefix(),
|
|
||||||
'table': table,
|
'table': table,
|
||||||
|
'permissions': permissions,
|
||||||
'bulk_querystring': bulk_querystring,
|
'bulk_querystring': bulk_querystring,
|
||||||
'active_tab': 'prefixes',
|
'active_tab': 'prefixes',
|
||||||
|
'first_available_prefix': instance.get_first_available_prefix(),
|
||||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
'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
|
# Find all IPRanges belonging to this Prefix
|
||||||
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
|
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'):
|
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
|
||||||
table.columns.show('pk')
|
table.columns.show('pk')
|
||||||
paginate_table(table, request)
|
paginate_table(table, request)
|
||||||
|
|
||||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
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 {
|
return {
|
||||||
'table': table,
|
'table': table,
|
||||||
|
'permissions': permissions,
|
||||||
'bulk_querystring': bulk_querystring,
|
'bulk_querystring': bulk_querystring,
|
||||||
'active_tab': 'ip-ranges',
|
'active_tab': 'ip-ranges',
|
||||||
}
|
}
|
||||||
@ -449,18 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView):
|
|||||||
if request.GET.get('show_available', 'true') == 'true':
|
if request.GET.get('show_available', 'true') == 'true':
|
||||||
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
|
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'):
|
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||||
table.columns.show('pk')
|
table.columns.show('pk')
|
||||||
paginate_table(table, request)
|
paginate_table(table, request)
|
||||||
|
|
||||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
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 {
|
return {
|
||||||
'first_available_ip': instance.get_first_available_ip(),
|
|
||||||
'table': table,
|
'table': table,
|
||||||
|
'permissions': permissions,
|
||||||
'bulk_querystring': bulk_querystring,
|
'bulk_querystring': bulk_querystring,
|
||||||
'active_tab': 'ip-addresses',
|
'active_tab': 'ip-addresses',
|
||||||
|
'first_available_ip': instance.get_first_available_ip(),
|
||||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
return list(queryset[self.offset:])
|
return list(queryset[self.offset:])
|
||||||
|
|
||||||
def get_limit(self, request):
|
def get_limit(self, request):
|
||||||
|
limit = super().get_limit(request)
|
||||||
|
|
||||||
if self.limit_query_param:
|
# Enforce maximum page size
|
||||||
try:
|
if settings.MAX_PAGE_SIZE:
|
||||||
limit = int(request.query_params[self.limit_query_param])
|
limit = min(limit, settings.MAX_PAGE_SIZE)
|
||||||
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
|
|
||||||
|
|
||||||
return self.default_limit
|
return limit
|
||||||
|
|
||||||
def get_next_link(self):
|
def get_next_link(self):
|
||||||
|
|
||||||
|
@ -113,6 +113,10 @@ class ExceptionHandlingMiddleware(object):
|
|||||||
|
|
||||||
def process_exception(self, request, exception):
|
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
|
# Don't catch exceptions when in debug mode
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
return
|
return
|
||||||
@ -121,10 +125,6 @@ class ExceptionHandlingMiddleware(object):
|
|||||||
if isinstance(exception, Http404):
|
if isinstance(exception, Http404):
|
||||||
return
|
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.
|
# Determine the type of exception. If it's a common issue, return a custom error page with instructions.
|
||||||
custom_template = None
|
custom_template = None
|
||||||
if isinstance(exception, ProgrammingError):
|
if isinstance(exception, ProgrammingError):
|
||||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.0.0'
|
VERSION = '3.0.1'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -560,6 +560,10 @@ RQ_QUEUES = {
|
|||||||
#
|
#
|
||||||
|
|
||||||
# Pagination
|
# 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 = [
|
PER_PAGE_DEFAULTS = [
|
||||||
25, 50, 100, 250, 500, 1000
|
25, 50, 100, 250, 500, 1000
|
||||||
]
|
]
|
||||||
|
@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
'table': table,
|
'table': table,
|
||||||
'permissions': permissions,
|
'permissions': permissions,
|
||||||
'action_buttons': self.action_buttons,
|
'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,
|
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
||||||
}
|
}
|
||||||
context.update(self.extra_context())
|
context.update(self.extra_context())
|
||||||
|
BIN
netbox/project-static/dist/config.js
vendored
BIN
netbox/project-static/dist/config.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js
vendored
BIN
netbox/project-static/dist/jobs.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js.map
vendored
BIN
netbox/project-static/dist/jobs.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js
vendored
BIN
netbox/project-static/dist/lldp.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -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 Masonry from 'masonry-layout';
|
||||||
import { getElements } from './util';
|
import { createElement, getElements } from './util';
|
||||||
|
|
||||||
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
|||||||
// plugins).
|
// plugins).
|
||||||
window.Collapse = Collapse;
|
window.Collapse = Collapse;
|
||||||
window.Modal = Modal;
|
window.Modal = Modal;
|
||||||
|
window.Popover = Popover;
|
||||||
window.Toast = Toast;
|
window.Toast = Toast;
|
||||||
window.Tooltip = Tooltip;
|
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.
|
* Enable any defined Bootstrap Tooltips.
|
||||||
*
|
*
|
||||||
* @see https://getbootstrap.com/docs/5.0/components/tooltips
|
* @see https://getbootstrap.com/docs/5.0/components/tooltips
|
||||||
*/
|
*/
|
||||||
export function initBootstrap(): void {
|
export function initBootstrap(): void {
|
||||||
for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) {
|
for (const func of [
|
||||||
|
initTooltips,
|
||||||
|
initModals,
|
||||||
|
initMasonry,
|
||||||
|
initTabs,
|
||||||
|
initImagePreview,
|
||||||
|
initSidebarAccordions,
|
||||||
|
]) {
|
||||||
func();
|
func();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,18 +13,26 @@ function initConfig(): void {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (hasError(data)) {
|
if (hasError(data)) {
|
||||||
createToast('danger', 'Error Fetching Device Config', data.error).show();
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
const configTypes = [
|
const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
|
||||||
'running',
|
|
||||||
'startup',
|
|
||||||
'candidate',
|
|
||||||
] as (keyof DeviceConfig['get_config'])[];
|
|
||||||
|
|
||||||
for (const configType of configTypes) {
|
for (const configType of configTypes) {
|
||||||
const element = document.getElementById(`${configType}_config`);
|
const element = document.getElementById(`${configType}_config`);
|
||||||
if (element !== null) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
|
* Get a select element's containing `.row` element.
|
||||||
@ -14,6 +14,38 @@ function fieldContainer(element: Nullable<HTMLSelectElement>): Nullable<HTMLElem
|
|||||||
return null;
|
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.
|
* Toggle element visibility when the mode field does not have a value.
|
||||||
*/
|
*/
|
||||||
@ -29,7 +61,7 @@ function handleModeNone(): void {
|
|||||||
resetSelect(untaggedVlan);
|
resetSelect(untaggedVlan);
|
||||||
resetSelect(taggedVlans);
|
resetSelect(taggedVlans);
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
toggleVisibility(fieldContainer(element), 'hide');
|
toggleVisibility(element, 'hide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,9 +78,9 @@ function handleModeAccess(): void {
|
|||||||
if (all(elements)) {
|
if (all(elements)) {
|
||||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||||
resetSelect(taggedVlans);
|
resetSelect(taggedVlans);
|
||||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
toggleVisibility(vlanGroup, 'show');
|
||||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
toggleVisibility(untaggedVlan, 'show');
|
||||||
toggleVisibility(fieldContainer(taggedVlans), 'hide');
|
toggleVisibility(taggedVlans, 'hide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,9 +95,9 @@ function handleModeTagged(): void {
|
|||||||
];
|
];
|
||||||
if (all(elements)) {
|
if (all(elements)) {
|
||||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||||
toggleVisibility(fieldContainer(taggedVlans), 'show');
|
toggleVisibility(taggedVlans, 'show');
|
||||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
toggleVisibility(vlanGroup, 'show');
|
||||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
toggleVisibility(untaggedVlan, 'show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,9 +113,9 @@ function handleModeTaggedAll(): void {
|
|||||||
if (all(elements)) {
|
if (all(elements)) {
|
||||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||||
resetSelect(taggedVlans);
|
resetSelect(taggedVlans);
|
||||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
toggleVisibility(vlanGroup, 'show');
|
||||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
toggleVisibility(untaggedVlan, 'show');
|
||||||
toggleVisibility(fieldContainer(taggedVlans), 'hide');
|
toggleVisibility(taggedVlans, 'hide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
14
netbox/project-static/src/global.d.ts
vendored
14
netbox/project-static/src/global.d.ts
vendored
@ -17,6 +17,11 @@ interface Window {
|
|||||||
*/
|
*/
|
||||||
Modal: typeof import('bootstrap').Modal;
|
Modal: typeof import('bootstrap').Modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap Popover Instance.
|
||||||
|
*/
|
||||||
|
Popover: typeof import('bootstrap').Popover;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap Toast Instance.
|
* Bootstrap Toast Instance.
|
||||||
*/
|
*/
|
||||||
@ -147,12 +152,15 @@ type LLDPNeighborDetail = {
|
|||||||
|
|
||||||
type DeviceConfig = {
|
type DeviceConfig = {
|
||||||
get_config: {
|
get_config: {
|
||||||
candidate: string;
|
candidate: string | Record<string, unknown>;
|
||||||
running: string;
|
running: string | Record<string, unknown>;
|
||||||
startup: string;
|
startup: string | Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DeviceConfigType = Exclude<keyof DeviceConfig['get_config'], 'error'>;
|
||||||
|
|
||||||
type DeviceEnvironment = {
|
type DeviceEnvironment = {
|
||||||
cpu?: {
|
cpu?: {
|
||||||
[core: string]: { '%usage': number };
|
[core: string]: { '%usage': number };
|
||||||
|
@ -320,6 +320,7 @@ export class APISelect {
|
|||||||
this.slim.slim.multiSelected.container.setAttribute('disabled', '');
|
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.slim.multiSelected.container.removeAttribute('disabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.slim.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -357,6 +359,11 @@ export class APISelect {
|
|||||||
this.fetchOptions(this.more, 'merge'),
|
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
|
// Create a unique iterator of all possible form fields which, when changed, should cause this
|
||||||
// element to update its API query.
|
// element to update its API query.
|
||||||
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
|
// 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.
|
* Process a valid API response and add results to this instance's options.
|
||||||
*
|
*
|
||||||
@ -398,13 +418,19 @@ export class APISelect {
|
|||||||
data: APIAnswer<APIObjectBase>,
|
data: APIAnswer<APIObjectBase>,
|
||||||
action: ApplyMethod = 'merge',
|
action: ApplyMethod = 'merge',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
|
// Get all already-selected options.
|
||||||
// existing object. When we fetch options from the API later, we can set any of the options
|
const preSelected = this.getPreselectedOptions();
|
||||||
// contained in this array to `selected`.
|
|
||||||
const selectOptions = Array.from(this.base.options)
|
// Get the values of all already-selected options.
|
||||||
.filter(option => option.selected)
|
const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
|
||||||
.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[];
|
let options = [] as Option[];
|
||||||
|
|
||||||
@ -441,12 +467,12 @@ export class APISelect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set option to disabled if it is contained within the disabled array.
|
// 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;
|
disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set pre-selected options.
|
// Set pre-selected options.
|
||||||
if (selectOptions.includes(value)) {
|
if (selectedValues.includes(value)) {
|
||||||
selected = true;
|
selected = true;
|
||||||
// If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
|
// 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.
|
// 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];
|
this.options = [...this.options, ...options];
|
||||||
break;
|
break;
|
||||||
case 'replace':
|
case 'replace':
|
||||||
this.options = options;
|
this.options = [...preSelectedOptions, ...options];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMore(data)) {
|
if (hasMore(data)) {
|
||||||
@ -558,6 +585,23 @@ export class APISelect {
|
|||||||
Promise.all([this.loadData()]);
|
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.
|
* 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 {
|
private getPlaceholder(): string {
|
||||||
let placeholder = this.name;
|
let placeholder = this.name;
|
||||||
if (this.base.id) {
|
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.
|
// Set the placeholder text to the label value, if it exists.
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
placeholder = `Select ${label.innerText.trim()}`;
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
|
@ -4,7 +4,7 @@ import { getElements } from '../util';
|
|||||||
export function initStaticSelect(): void {
|
export function initStaticSelect(): void {
|
||||||
for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
|
for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
|
||||||
if (select !== null) {
|
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;
|
let placeholder;
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Cookie from 'cookie';
|
import Cookie from 'cookie';
|
||||||
|
import queryString from 'query-string';
|
||||||
|
|
||||||
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
type ReqData = URLSearchParams | Dict | undefined | unknown;
|
type ReqData = URLSearchParams | Dict | undefined | unknown;
|
||||||
@ -11,14 +12,16 @@ type InferredProps<
|
|||||||
// Element name.
|
// Element name.
|
||||||
T extends keyof HTMLElementTagNameMap,
|
T extends keyof HTMLElementTagNameMap,
|
||||||
// Element type.
|
// Element type.
|
||||||
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T]
|
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
|
||||||
> = Partial<Record<keyof E, E[keyof E]>>;
|
> = Partial<Record<keyof E, E[keyof E]>>;
|
||||||
|
|
||||||
export function isApiError(data: Record<string, unknown>): data is APIError {
|
export function isApiError(data: Record<string, unknown>): data is APIError {
|
||||||
return 'error' in data && 'exception' in data;
|
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;
|
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.
|
* Retrieve the CSRF token from cookie storage.
|
||||||
*/
|
*/
|
||||||
export function getCsrfToken(): string {
|
function getCsrfToken(): string {
|
||||||
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
|
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
|
||||||
if (typeof csrfToken === 'undefined') {
|
if (typeof csrfToken === 'undefined') {
|
||||||
throw new Error('Invalid or missing CSRF token');
|
throw new Error('Invalid or missing CSRF token');
|
||||||
@ -102,8 +105,60 @@ export function getCsrfToken(): string {
|
|||||||
return csrfToken;
|
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>(
|
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
|
||||||
url: string,
|
path: string,
|
||||||
method: Method,
|
method: Method,
|
||||||
data?: D,
|
data?: D,
|
||||||
): Promise<APIResponse<R>> {
|
): Promise<APIResponse<R>> {
|
||||||
@ -115,6 +170,7 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
|
|||||||
body = JSON.stringify(data);
|
body = JSON.stringify(data);
|
||||||
headers.set('content-type', 'application/json');
|
headers.set('content-type', 'application/json');
|
||||||
}
|
}
|
||||||
|
const url = buildUrl(path);
|
||||||
|
|
||||||
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
|
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
|
||||||
const contentType = res.headers.get('Content-Type');
|
const contentType = res.headers.get('Content-Type');
|
||||||
@ -367,8 +423,13 @@ export function createElement<
|
|||||||
// Element props.
|
// Element props.
|
||||||
P extends InferredProps<T>,
|
P extends InferredProps<T>,
|
||||||
// Child element type.
|
// Child element type.
|
||||||
C extends HTMLElement = HTMLElement
|
C extends HTMLElement = HTMLElement,
|
||||||
>(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] {
|
>(
|
||||||
|
tag: T,
|
||||||
|
properties: P | null,
|
||||||
|
classes: Nullable<string[]> = null,
|
||||||
|
children: C[] = [],
|
||||||
|
): HTMLElementTagNameMap[T] {
|
||||||
// Create the base element.
|
// Create the base element.
|
||||||
const element = document.createElement<T>(tag);
|
const element = document.createElement<T>(tag);
|
||||||
|
|
||||||
@ -384,7 +445,9 @@ export function createElement<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add each CSS class to the element's class list.
|
// 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) {
|
for (const child of children) {
|
||||||
// Add each child element to the base element.
|
// Add each child element to the base element.
|
||||||
|
@ -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 {
|
#django-messages {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: $spacer;
|
right: $spacer;
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Server Error</title>
|
<title>Server Error</title>
|
||||||
<link rel="stylesheet" href="{% static 'netbox.css'%}" />
|
<link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-6 offset-md-3">
|
<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">
|
<h5 class="card-header">
|
||||||
<i class="mdi mdi-alert"></i> Server Error
|
<i class="mdi mdi-alert"></i> Server Error
|
||||||
</h5>
|
</h5>
|
||||||
@ -32,7 +32,7 @@
|
|||||||
Python version: {{ python_version }}
|
Python version: {{ python_version }}
|
||||||
NetBox version: {{ netbox_version }}</pre>
|
NetBox version: {{ netbox_version }}</pre>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
|
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
data-netbox-path="{{ request.path }}"
|
data-netbox-path="{{ request.path }}"
|
||||||
|
data-netbox-base-path="{{ settings.BASE_PATH }}"
|
||||||
{% if preferences|get_key:'ui.colormode' == 'dark'%}
|
{% if preferences|get_key:'ui.colormode' == 'dark'%}
|
||||||
data-netbox-color-mode="dark"
|
data-netbox-color-mode="dark"
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -7,12 +7,12 @@
|
|||||||
{# Brand #}
|
{# Brand #}
|
||||||
|
|
||||||
{# Full Logo #}
|
{# 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">
|
<img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Icon Logo #}
|
{# 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">
|
<img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="NetBox Logo">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -109,8 +109,8 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if object.physical_address %}
|
{% if object.physical_address %}
|
||||||
<div class="float-end noprint">
|
<div class="float-end noprint">
|
||||||
<a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
|
<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
|
<i class="mdi mdi-map-marker"></i> Map It
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||||
@ -129,7 +129,7 @@
|
|||||||
{% if object.latitude and object.longitude %}
|
{% if object.latitude and object.longitude %}
|
||||||
<div class="float-end noprint">
|
<div class="float-end noprint">
|
||||||
<a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
|
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block form %}
|
||||||
<form action="{% querystring request %}" method="post" class="form form-horizontal">
|
<form action="{% querystring request %}" method="post" class="form form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for field in form.hidden_fields %}
|
{% for field in form.hidden_fields %}
|
||||||
@ -17,13 +17,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col col-md-8 offset-md-2">
|
<div class="col col-md-8 offset-md-2">
|
||||||
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
|
<div class="field-group">
|
||||||
<div class="card">
|
<h6>Select IP Address</h6>
|
||||||
<h5 class="card-header">Select IP Address</h5>
|
{% render_field form.vrf_id %}
|
||||||
<div class="card-body">
|
{% render_field form.q %}
|
||||||
{% render_field form.vrf_id %}
|
|
||||||
{% render_field form.q %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -42,4 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock form %}
|
||||||
|
|
||||||
|
{% block buttons %}
|
||||||
|
{% endblock buttons%}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
{% extends 'ipam/prefix/base.html' %}
|
{% extends 'ipam/prefix/base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block extra_controls %}
|
{% block extra_controls %}
|
||||||
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
|
{% 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-primary">
|
<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
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -11,7 +13,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<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' %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
{% table_config_form table table_name="IPAddressTable" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
{% extends 'ipam/prefix/base.html' %}
|
{% extends 'ipam/prefix/base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{% table_config_form table table_name="IPRangeTable" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -2,20 +2,11 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block buttons %}
|
{% block extra_controls %}
|
||||||
{% include 'ipam/inc/toggle_available.html' %}
|
{% include 'ipam/inc/toggle_available.html' %}
|
||||||
{% if request.user.is_authenticated and table_config_form %}
|
{% if perms.ipam.add_prefix and first_available_prefix %}
|
||||||
<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>
|
<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">
|
||||||
{% endif %}
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
|
||||||
{% 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
|
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@ -24,12 +15,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<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 %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% table_config_form prefix_table table_name="PrefixDetailTable" %}
|
{% table_config_form table table_name="PrefixDetailTable" %}
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<script src="{% static 'js/tableconfig.js' %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP process.
|
The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP process.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -9,5 +9,5 @@
|
|||||||
{% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %}
|
{% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %}
|
||||||
|
|
||||||
{% block branding %}
|
{% block branding %}
|
||||||
<a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
|
<a class="navbar-brand" href="{% url 'home' %}">NetBox</a>
|
||||||
{% endblock branding %}
|
{% endblock branding %}
|
||||||
|
@ -1,40 +1,44 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% if permissions.change or permissions.delete %}
|
{% if permissions.change or permissions.delete %}
|
||||||
<form method="post" class="form form-horizontal">
|
<form method="post" class="form form-horizontal">
|
||||||
{% csrf_token %}
|
{% 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 %}" />
|
<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 %}
|
{% if table.paginator.num_pages > 1 %}
|
||||||
<div id="select-all-box" class="d-none card noprint">
|
<div id="select-all-box" class="d-none card noprint">
|
||||||
<div class="card-body">
|
<div class="float-end">
|
||||||
<div class="float-end">
|
{% if bulk_edit_url and permissions.change %}
|
||||||
{% 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">
|
||||||
<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
|
||||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
|
</button>
|
||||||
</button>
|
{% endif %}
|
||||||
{% endif %}
|
{% if bulk_delete_url and permissions.delete %}
|
||||||
{% 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">
|
||||||
<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
|
||||||
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
|
</button>
|
||||||
</button>
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="form-check">
|
||||||
<div class="form-check">
|
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
|
||||||
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
|
<label for="select-all" class="form-check-label">
|
||||||
<label for="select-all" class="form-check-label">
|
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
|
||||||
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
|
</label>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include table_template|default:'inc/responsive_table.html' %}
|
{% include table_template|default:'inc/responsive_table.html' %}
|
||||||
|
|
||||||
<div class="float-start noprint">
|
<div class="float-start noprint">
|
||||||
{% block extra_actions %}{% endblock %}
|
{% block extra_actions %}{% endblock %}
|
||||||
|
|
||||||
{% if bulk_edit_url and permissions.change %}
|
{% 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">
|
<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
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if bulk_delete_url and permissions.delete %}
|
{% 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">
|
<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
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
|
||||||
@ -43,7 +47,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{% include table_template|default:'inc/responsive_table.html' %}
|
{% include table_template|default:'inc/responsive_table.html' %}
|
||||||
|
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
@ -7,11 +7,11 @@
|
|||||||
<h5 class="modal-title">Table Configuration</h5>
|
<h5 class="modal-title">Table Configuration</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</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="modal-body row">
|
||||||
<div class="col-5 text-center">
|
<div class="col-5 text-center">
|
||||||
{{ table_config_form.available_columns.label }}
|
{{ form.available_columns.label }}
|
||||||
{{ table_config_form.available_columns }}
|
{{ form.available_columns }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 d-flex align-items-center">
|
<div class="col-2 d-flex align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@ -24,8 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5 text-center">
|
<div class="col-5 text-center">
|
||||||
{{ table_config_form.columns.label }}
|
{{ form.columns.label }}
|
||||||
{{ table_config_form.columns }}
|
{{ form.columns }}
|
||||||
<a class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_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
|
<i class="mdi mdi-arrow-up-bold"></i> Move Up
|
||||||
</a>
|
</a>
|
||||||
|
@ -131,7 +131,7 @@
|
|||||||
<th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
|
<th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.memory %}
|
{% if object.memory %}
|
||||||
{{ object.memory|humanize_megabytes }} MB
|
{{ object.memory|humanize_megabytes }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -48,7 +48,8 @@ def is_api_request(request):
|
|||||||
Return True of the request is being made via the REST API.
|
Return True of the request is being made via the REST API.
|
||||||
"""
|
"""
|
||||||
api_path = reverse('api-root')
|
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):
|
def get_view_name(view, suffix=None):
|
||||||
|
@ -435,7 +435,7 @@ class DynamicModelChoiceMixin:
|
|||||||
filter = self.filter(field_name=field_name)
|
filter = self.filter(field_name=field_name)
|
||||||
try:
|
try:
|
||||||
self.queryset = filter.filter(self.queryset, data)
|
self.queryset = filter.filter(self.queryset, data)
|
||||||
except TypeError:
|
except (TypeError, ValueError):
|
||||||
# Catch any error caused by invalid initial data passed from the user
|
# Catch any error caused by invalid initial data passed from the user
|
||||||
self.queryset = self.queryset.none()
|
self.queryset = self.queryset.none()
|
||||||
else:
|
else:
|
||||||
|
@ -119,13 +119,14 @@ def get_selected_values(form, field_name):
|
|||||||
"""
|
"""
|
||||||
if not hasattr(form, 'cleaned_data'):
|
if not hasattr(form, 'cleaned_data'):
|
||||||
form.is_valid()
|
form.is_valid()
|
||||||
|
filter_data = form.cleaned_data.get(field_name)
|
||||||
|
|
||||||
# Selection field
|
# Selection field
|
||||||
if hasattr(form.fields[field_name], 'choices'):
|
if hasattr(form.fields[field_name], 'choices'):
|
||||||
try:
|
try:
|
||||||
choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
|
choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
|
||||||
return [
|
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:
|
except TypeError:
|
||||||
# Field uses dynamic choices. Show all that have been populated.
|
# Field uses dynamic choices. Show all that have been populated.
|
||||||
@ -134,7 +135,7 @@ def get_selected_values(form, field_name):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Non-selection field
|
# Non-selection field
|
||||||
return [str(form.cleaned_data[field_name])]
|
return [str(filter_data)]
|
||||||
|
|
||||||
|
|
||||||
def add_blank_choice(choices):
|
def add_blank_choice(choices):
|
||||||
|
@ -185,7 +185,7 @@ class APISelect(SelectWithDisabled):
|
|||||||
# layer.
|
# layer.
|
||||||
if key in self.static_params:
|
if key in self.static_params:
|
||||||
current = self.static_params[key]
|
current = self.static_params[key]
|
||||||
self.static_params[key] = [*current, value]
|
self.static_params[key] = [v for v in set([*current, value])]
|
||||||
else:
|
else:
|
||||||
self.static_params[key] = [value]
|
self.static_params[key] = [value]
|
||||||
else:
|
else:
|
||||||
@ -194,7 +194,7 @@ class APISelect(SelectWithDisabled):
|
|||||||
# `$`).
|
# `$`).
|
||||||
if key in self.static_params:
|
if key in self.static_params:
|
||||||
current = self.static_params[key]
|
current = self.static_params[key]
|
||||||
self.static_params[key] = [*current, value]
|
self.static_params[key] = [v for v in set([*current, value])]
|
||||||
else:
|
else:
|
||||||
self.static_params[key] = [value]
|
self.static_params[key] = [value]
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ class Command(_Command):
|
|||||||
raise CommandError(
|
raise CommandError(
|
||||||
"This command is available for development purposes only. It will\n"
|
"This command is available for development purposes only. It will\n"
|
||||||
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
||||||
"please post to the NetBox mailing list:\n"
|
"please post to the NetBox discussion forum on GitHub:\n"
|
||||||
" https://groups.google.com/g/netbox-discuss"
|
" https://github.com/netbox-community/netbox/discussions"
|
||||||
)
|
)
|
||||||
|
|
||||||
super().handle(*args, **kwargs)
|
super().handle(*args, **kwargs)
|
||||||
|
@ -49,21 +49,25 @@ class EnhancedPage(Page):
|
|||||||
|
|
||||||
def get_paginate_count(request):
|
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
|
1. per_page URL query parameter
|
||||||
2. Saved user preference
|
2. Saved user preference
|
||||||
3. PAGINATE_COUNT global setting.
|
3. PAGINATE_COUNT global setting.
|
||||||
|
|
||||||
|
Return the lesser of the calculated value and MAX_PAGE_SIZE.
|
||||||
"""
|
"""
|
||||||
if 'per_page' in request.GET:
|
if 'per_page' in request.GET:
|
||||||
try:
|
try:
|
||||||
per_page = int(request.GET.get('per_page'))
|
per_page = int(request.GET.get('per_page'))
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
request.user.config.set('pagination.per_page', per_page, commit=True)
|
request.user.config.set('pagination.per_page', per_page, commit=True)
|
||||||
return per_page
|
return min(per_page, settings.MAX_PAGE_SIZE)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
|
per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
|
||||||
return settings.PAGINATE_COUNT
|
return min(per_page, settings.MAX_PAGE_SIZE)
|
||||||
|
|
||||||
|
return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)
|
||||||
|
@ -237,9 +237,13 @@ class ContentTypeColumn(tables.Column):
|
|||||||
Display a ContentType instance.
|
Display a ContentType instance.
|
||||||
"""
|
"""
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
return content_type_name(value)
|
return content_type_name(value)
|
||||||
|
|
||||||
def value(self, value):
|
def value(self, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
return f"{value.app_label}.{value.model}"
|
return f"{value.app_label}.{value.model}"
|
||||||
|
|
||||||
|
|
||||||
|
@ -304,28 +304,6 @@ def get_item(value: object, attr: str) -> Any:
|
|||||||
return value[attr]
|
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
|
@register.filter
|
||||||
def status_from_tag(tag: str = "info") -> str:
|
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')
|
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')
|
@register.inclusion_tag('utilities/templatetags/utilization_graph.html')
|
||||||
def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
|
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):
|
def table_config_form(table, table_name=None):
|
||||||
return {
|
return {
|
||||||
'table_name': table_name or table.__class__.__name__,
|
'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.
|
Display the active filters for a given filter form.
|
||||||
"""
|
"""
|
||||||
form.is_valid()
|
form.is_valid()
|
||||||
|
querydict = query_params.copy()
|
||||||
|
|
||||||
applied_filters = []
|
applied_filters = []
|
||||||
for filter_name in form.changed_data:
|
for filter_name in form.changed_data:
|
||||||
if filter_name not in query_params:
|
if filter_name not in querydict:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
|
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
|
||||||
querydict = query_params.copy()
|
|
||||||
querydict.pop(filter_name)
|
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({
|
applied_filters.append({
|
||||||
'name': filter_name,
|
'name': filter_name,
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import urllib
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from itertools import count, groupby
|
from itertools import count, groupby
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
from django.core.serializers import serialize
|
from django.core.serializers import serialize
|
||||||
from django.db.models import Count, OuterRef, Subquery
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
@ -286,6 +288,45 @@ def flatten_dict(d, prefix='', separator='.'):
|
|||||||
return ret
|
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)
|
# Taken from django.utils.functional (<3.0)
|
||||||
def curry(_curried_func, *args, **kwargs):
|
def curry(_curried_func, *args, **kwargs):
|
||||||
def _curried(*moreargs, **morekwargs):
|
def _curried(*moreargs, **morekwargs):
|
||||||
@ -307,8 +348,12 @@ def content_type_name(contenttype):
|
|||||||
"""
|
"""
|
||||||
Return a proper ContentType name.
|
Return a proper ContentType name.
|
||||||
"""
|
"""
|
||||||
meta = contenttype.model_class()._meta
|
try:
|
||||||
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
|
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}'
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
Django==3.2.6
|
Django==3.2.7
|
||||||
django-cors-headers==3.8.0
|
django-cors-headers==3.8.0
|
||||||
django-debug-toolbar==3.2.2
|
django-debug-toolbar==3.2.2
|
||||||
django-filter==2.4.0
|
django-filter==2.4.0
|
||||||
|
Loading…
Reference in New Issue
Block a user