mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -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
|
||||
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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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!"
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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())
|
||||
|
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 { 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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
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;
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
|
@ -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()}`;
|
||||
|
@ -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) {
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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%}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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">—</span>
|
||||
{% endif %}
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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}"
|
||||
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}'
|
||||
|
||||
|
||||
#
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user