diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 78231890b48..332a0ad75a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.6 + placeholder: v3.2.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 71d45092c7b..ff9b5e3587d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.6 + placeholder: v3.2.7 validations: required: true - type: dropdown diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index b405ed09a2b..fca9eab5e6c 100644 --- a/docs/administration/authentication/overview.md +++ b/docs/administration/authentication/overview.md @@ -34,4 +34,4 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2' NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options. -Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. +Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.) diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index d0137938d13..9f64401ae59 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -43,7 +43,7 @@ The following data is available as context for Jinja2 templates: * `username` - The name of the user account associated with the change. * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. -* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. +* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. ### Default Request Body diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 8775da01fea..35e9b9a2233 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,26 @@ # NetBox v3.2 +## v3.2.7 (2022-07-20) + +### Enhancements + +* [#9705](https://github.com/netbox-community/netbox/issues/9705) - Support filter expressions for the `serial` field on racks, devices, and inventory items +* [#9741](https://github.com/netbox-community/netbox/issues/9741) - Check for UserConfig instance during user login +* [#9745](https://github.com/netbox-community/netbox/issues/9745) - Add wireless LANs and links to global search + +### Bug Fixes + +* [#9437](https://github.com/netbox-community/netbox/issues/9437) - Standardize form submission buttons and behavior when using enter key +* [#9499](https://github.com/netbox-community/netbox/issues/9499) - Fix filtered bulk deletion of VM Interfaces +* [#9634](https://github.com/netbox-community/netbox/issues/9634) - Fix image URLs in rack elevations when using external storage +* [#9715](https://github.com/netbox-community/netbox/issues/9715) - Fix `SOCIAL_AUTH_PIPELINE` config parameter not taking effect +* [#9754](https://github.com/netbox-community/netbox/issues/9754) - Fix regression introduced by #9632 +* [#9746](https://github.com/netbox-community/netbox/issues/9746) - Permit filtering interfaces by arbitrary speed value in UI +* [#9749](https://github.com/netbox-community/netbox/issues/9749) - Retain original slug values when modifying object names +* [#9775](https://github.com/netbox-community/netbox/issues/9775) - Fix exception when viewing a report with no description + +--- + ## v3.2.6 (2022-07-11) ### Enhancements diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c2cb846a925..d95480aa770 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -15,6 +15,7 @@ from netbox.api.serializers import ( NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from netbox.config import ConfigItem +from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -41,7 +42,7 @@ class LinkTerminationSerializer(serializers.ModelSerializer): Return the appropriate serializer for the link termination model. """ if obj._link_peer is not None: - serializer = get_serializer_for_model(obj._link_peer, prefix='Nested') + serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj._link_peer, context=context).data return None @@ -67,7 +68,7 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer): Return the appropriate serializer for the type of connected object. """ if obj._path is not None and obj._path.destination is not None: - serializer = get_serializer_for_model(obj._path.destination, prefix='Nested') + serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj._path.destination, context=context).data return None @@ -543,7 +544,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer): def get_component(self, obj): if obj.component is None: return None - serializer = get_serializer_for_model(obj.component, prefix='Nested') + serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.component, context=context).data @@ -935,7 +936,7 @@ class InventoryItemSerializer(NetBoxModelSerializer): def get_component(self, obj): if obj.component is None: return None - serializer = get_serializer_for_model(obj.component, prefix='Nested') + serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.component, context=context).data @@ -991,7 +992,7 @@ class CableSerializer(NetBoxModelSerializer): termination = getattr(obj, 'termination_{}'.format(side.lower())) if termination is None: return None - serializer = get_serializer_for_model(termination, prefix='Nested') + serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} data = serializer(termination, context=context).data @@ -1037,7 +1038,7 @@ class CablePathSerializer(serializers.ModelSerializer): """ Return the appropriate serializer for the origin. """ - serializer = get_serializer_for_model(obj.origin, prefix='Nested') + serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.origin, context=context).data @@ -1047,7 +1048,7 @@ class CablePathSerializer(serializers.ModelSerializer): Return the appropriate serializer for the destination, if any. """ if obj.destination_id is not None: - serializer = get_serializer_for_model(obj.destination, prefix='Nested') + serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.destination, context=context).data return None @@ -1056,7 +1057,7 @@ class CablePathSerializer(serializers.ModelSerializer): def get_path(self, obj): ret = [] for node in obj.get_path(): - serializer = get_serializer_for_model(node, prefix='Nested') + serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} ret.append(serializer(node, context=context).data) return ret diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3fa652a9ba1..6387e26f873 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -22,6 +22,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config +from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.utils import count_related from virtualization.models import VirtualMachine @@ -69,14 +70,14 @@ class PathEndpointMixin(object): break # Serialize each object - serializer_a = get_serializer_for_model(near_end, prefix='Nested') + serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX) x = serializer_a(near_end, context={'request': request}).data if cable is not None: y = serializers.TracedCableSerializer(cable, context={'request': request}).data else: y = None if far_end is not None: - serializer_b = get_serializer_for_model(far_end, prefix='Nested') + serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX) z = serializer_b(far_end, context={'request': request}).data else: z = None diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4b420157858..f5342106e10 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -307,7 +307,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe to_field_name='slug', label='Role (slug)', ) - serial = django_filters.CharFilter( + serial = MultiValueCharFilter( lookup_expr='iexact' ) @@ -1002,10 +1002,13 @@ class ModuleFilterSet(NetBoxModelFilterSet): queryset=Device.objects.all(), label='Device (ID)', ) + serial = MultiValueCharFilter( + lookup_expr='iexact' + ) class Meta: model = Module - fields = ['id', 'serial', 'asset_tag'] + fields = ['id', 'asset_tag'] def search(self, queryset, name, value): if not value.strip(): @@ -1400,7 +1403,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): ) component_type = ContentTypeFilter() component_id = MultiValueNumberFilter() - serial = django_filters.CharFilter( + serial = MultiValueCharFilter( lookup_expr='iexact' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1535e57184c..38221b371f2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -982,8 +982,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ) speed = forms.IntegerField( required=False, - label='Select Speed', - widget=SelectSpeedWidget(attrs={'readonly': None}) + label='Speed', + widget=SelectSpeedWidget() ) duplex = MultipleChoiceField( choices=InterfaceDuplexChoices, diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 1de68ec3659..4c093a3125b 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -113,8 +113,12 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: + url = device.device_type.front_image.url + # Convert any relative URLs to absolute + if url.startswith('/'): + url = '{}{}'.format(self.base_url, url) image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.front_image.url), + href=url, insert=start, size=end, class_='device-image' @@ -139,8 +143,12 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: + url = device.device_type.rear_image.url + # Convert any relative URLs to absolute + if url.startswith('/'): + url = '{}{}'.format(self.base_url, url) image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.rear_image.url), + href=url, insert=start, size=end, class_='device-image' diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 47aa9368c23..cf0f397df50 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -494,10 +494,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_serial(self): - params = {'serial': 'ABC'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'serial': 'abc'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'serial': ['ABC', 'DEF']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'serial': ['abc', 'def']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_tenant(self): tenants = Tenant.objects.all()[:2] @@ -1860,7 +1860,9 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_serial(self): - params = {'asset_tag': ['A', 'B']} + params = {'serial': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'serial': ['a', 'b']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asset_tag(self): @@ -3413,10 +3415,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_serial(self): - params = {'serial': 'ABC'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'serial': 'abc'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'serial': ['ABC', 'DEF']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'serial': ['abc', 'def']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_component_type(self): params = {'component_type': 'dcim.interface'} diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a466246f5b2..ec3e9152e14 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -560,9 +560,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): # class RackListView(generic.ObjectListView): - queryset = Rack.objects.prefetch_related( - 'site', 'location', 'tenant', 'tenant_group', 'role', 'devices__device_type' - ).annotate( + queryset = Rack.objects.prefetch_related('devices__device_type').annotate( device_count=count_related(Device, 'rack') ) filterset = filtersets.RackFilterSet diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index fd6e1f55027..b7fd1e129c6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -3,6 +3,7 @@ from rest_framework.fields import Field from extras.choices import CustomFieldTypeChoices from extras.models import CustomField +from netbox.constants import NESTED_SERIALIZER_PREFIX # @@ -51,10 +52,10 @@ class CustomFieldsDataField(Field): for cf in self._get_custom_fields(): value = cf.deserialize(obj.get(cf.name)) if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) value = serializer(value, context=self.parent.context).data elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) value = serializer(value, many=True, context=self.parent.context).data data[cf.name] = value diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index bdb54067ae3..a4e3c6609ff 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -15,6 +15,7 @@ from extras.utils import FeatureQuery from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer +from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -192,7 +193,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_parent(self, obj): - serializer = get_serializer_for_model(obj.parent, prefix='Nested') + serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX) return serializer(obj.parent, context={'request': self.context['request']}).data @@ -242,7 +243,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested') + serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(instance.assigned_object, context=context).data @@ -462,7 +463,7 @@ class ObjectChangeSerializer(BaseModelSerializer): return None try: - serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') + serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX) except SerializerNotFound: return obj.object_repr context = { diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3fa1bcc7e35..4462ed69742 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -10,6 +10,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer +from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer @@ -145,7 +146,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): def get_interface(self, obj): if obj.interface is None: return None - serializer = get_serializer_for_model(obj.interface, prefix='Nested') + serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.interface, context=context).data @@ -191,7 +192,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): def get_scope(self, obj): if obj.scope_id is None: return None - serializer = get_serializer_for_model(obj.scope, prefix='Nested') + serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.scope, context=context).data @@ -375,7 +376,7 @@ class IPAddressSerializer(NetBoxModelSerializer): def get_assigned_object(self, obj): if obj.assigned_object is None: return None - serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.assigned_object, context=context).data diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e6b11c4274c..706670cadfa 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -586,9 +586,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/iprange/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', 'tenant__group', - ) + return parent.get_child_ips().restrict(request.user, 'view') def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 462c07c6f9e..0f149240d51 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet from extras.models import ExportTemplate from netbox.api.exceptions import SerializerNotFound +from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from .mixins import * @@ -60,7 +61,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali if self.brief: logger.debug("Request is for 'brief' format; initializing nested serializer") try: - serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') + serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX) logger.debug(f"Using serializer {serializer}") return serializer except SerializerNotFound: diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index cc04e9aa83e..776938a976c 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,256 +1,5 @@ -from collections import OrderedDict -from typing import Dict - -import circuits.filtersets -import circuits.tables -import dcim.filtersets -import dcim.tables -import ipam.filtersets -import ipam.tables -import tenancy.filtersets -import tenancy.tables -import virtualization.filtersets -import virtualization.tables -from circuits.models import Circuit, ProviderNetwork, Provider -from dcim.models import ( - Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, -) -from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF -from tenancy.models import Contact, Tenant, ContactAssignment -from utilities.utils import count_related -from virtualization.models import Cluster, VirtualMachine +# Prefix for nested serializers +NESTED_SERIALIZER_PREFIX = 'Nested' +# Max results per object type SEARCH_MAX_RESULTS = 15 - -CIRCUIT_TYPES = OrderedDict( - ( - ('provider', { - 'queryset': Provider.objects.annotate( - count_circuits=count_related(Circuit, 'provider') - ), - 'filterset': circuits.filtersets.ProviderFilterSet, - 'table': circuits.tables.ProviderTable, - 'url': 'circuits:provider_list', - }), - ('circuit', { - 'queryset': Circuit.objects.prefetch_related( - 'type', 'provider', 'tenant', 'tenant__group', 'terminations__site' - ), - 'filterset': circuits.filtersets.CircuitFilterSet, - 'table': circuits.tables.CircuitTable, - 'url': 'circuits:circuit_list', - }), - ('providernetwork', { - 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': circuits.filtersets.ProviderNetworkFilterSet, - 'table': circuits.tables.ProviderNetworkTable, - 'url': 'circuits:providernetwork_list', - }), - ) -) - - -DCIM_TYPES = OrderedDict( - ( - ('site', { - 'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'), - 'filterset': dcim.filtersets.SiteFilterSet, - 'table': dcim.tables.SiteTable, - 'url': 'dcim:site_list', - }), - ('rack', { - 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate( - device_count=count_related(Device, 'rack') - ), - 'filterset': dcim.filtersets.RackFilterSet, - 'table': dcim.tables.RackTable, - 'url': 'dcim:rack_list', - }), - ('rackreservation', { - 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': dcim.filtersets.RackReservationFilterSet, - 'table': dcim.tables.RackReservationTable, - 'url': 'dcim:rackreservation_list', - }), - ('location', { - 'queryset': Location.objects.add_related_count( - Location.objects.add_related_count( - Location.objects.all(), - Device, - 'location', - 'device_count', - cumulative=True - ), - Rack, - 'location', - 'rack_count', - cumulative=True - ).prefetch_related('site'), - 'filterset': dcim.filtersets.LocationFilterSet, - 'table': dcim.tables.LocationTable, - 'url': 'dcim:location_list', - }), - ('devicetype', { - 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=count_related(Device, 'device_type') - ), - 'filterset': dcim.filtersets.DeviceTypeFilterSet, - 'table': dcim.tables.DeviceTypeTable, - 'url': 'dcim:devicetype_list', - }), - ('device', { - 'queryset': Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4', 'primary_ip6', - ), - 'filterset': dcim.filtersets.DeviceFilterSet, - 'table': dcim.tables.DeviceTable, - 'url': 'dcim:device_list', - }), - ('moduletype', { - 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( - instance_count=count_related(Module, 'module_type') - ), - 'filterset': dcim.filtersets.ModuleTypeFilterSet, - 'table': dcim.tables.ModuleTypeTable, - 'url': 'dcim:moduletype_list', - }), - ('module', { - 'queryset': Module.objects.prefetch_related( - 'module_type__manufacturer', 'device', 'module_bay', - ), - 'filterset': dcim.filtersets.ModuleFilterSet, - 'table': dcim.tables.ModuleTable, - 'url': 'dcim:module_list', - }), - ('virtualchassis', { - 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( - member_count=count_related(Device, 'virtual_chassis') - ), - 'filterset': dcim.filtersets.VirtualChassisFilterSet, - 'table': dcim.tables.VirtualChassisTable, - 'url': 'dcim:virtualchassis_list', - }), - ('cable', { - 'queryset': Cable.objects.all(), - 'filterset': dcim.filtersets.CableFilterSet, - 'table': dcim.tables.CableTable, - 'url': 'dcim:cable_list', - }), - ('powerfeed', { - 'queryset': PowerFeed.objects.all(), - 'filterset': dcim.filtersets.PowerFeedFilterSet, - 'table': dcim.tables.PowerFeedTable, - 'url': 'dcim:powerfeed_list', - }), - ) -) - -IPAM_TYPES = OrderedDict( - ( - ('vrf', { - 'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'), - 'filterset': ipam.filtersets.VRFFilterSet, - 'table': ipam.tables.VRFTable, - 'url': 'ipam:vrf_list', - }), - ('aggregate', { - 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': ipam.filtersets.AggregateFilterSet, - 'table': ipam.tables.AggregateTable, - 'url': 'ipam:aggregate_list', - }), - ('prefix', { - 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'), - 'filterset': ipam.filtersets.PrefixFilterSet, - 'table': ipam.tables.PrefixTable, - 'url': 'ipam:prefix_list', - }), - ('ipaddress', { - 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'), - 'filterset': ipam.filtersets.IPAddressFilterSet, - 'table': ipam.tables.IPAddressTable, - 'url': 'ipam:ipaddress_list', - }), - ('vlan', { - 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'), - 'filterset': ipam.filtersets.VLANFilterSet, - 'table': ipam.tables.VLANTable, - 'url': 'ipam:vlan_list', - }), - ('asn', { - 'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'), - 'filterset': ipam.filtersets.ASNFilterSet, - 'table': ipam.tables.ASNTable, - 'url': 'ipam:asn_list', - }), - ('service', { - 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), - 'filterset': ipam.filtersets.ServiceFilterSet, - 'table': ipam.tables.ServiceTable, - 'url': 'ipam:service_list', - }), - ) -) - -TENANCY_TYPES = OrderedDict( - ( - ('tenant', { - 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': tenancy.filtersets.TenantFilterSet, - 'table': tenancy.tables.TenantTable, - 'url': 'tenancy:tenant_list', - }), - ('contact', { - 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( - assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': tenancy.filtersets.ContactFilterSet, - 'table': tenancy.tables.ContactTable, - 'url': 'tenancy:contact_list', - }), - ) -) - -VIRTUALIZATION_TYPES = OrderedDict( - ( - ('cluster', { - 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( - device_count=count_related(Device, 'cluster'), - vm_count=count_related(VirtualMachine, 'cluster') - ), - 'filterset': virtualization.filtersets.ClusterFilterSet, - 'table': virtualization.tables.ClusterTable, - 'url': 'virtualization:cluster_list', - }), - ('virtualmachine', { - 'queryset': VirtualMachine.objects.prefetch_related( - 'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6', - ), - 'filterset': virtualization.filtersets.VirtualMachineFilterSet, - 'table': virtualization.tables.VirtualMachineTable, - 'url': 'virtualization:virtualmachine_list', - }), - ) -) - -SEARCH_TYPE_HIERARCHY = OrderedDict( - ( - ("Circuits", CIRCUIT_TYPES), - ("DCIM", DCIM_TYPES), - ("IPAM", IPAM_TYPES), - ("Tenancy", TENANCY_TYPES), - ("Virtualization", VIRTUALIZATION_TYPES), - ) -) - - -def build_search_types() -> Dict[str, Dict]: - result = dict() - - for app_types in SEARCH_TYPE_HIERARCHY.values(): - for name, items in app_types.items(): - result[name] = items - - return result - - -SEARCH_TYPES = build_search_types() diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 1a72d815913..f509afa5bd3 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -125,7 +125,7 @@ class BaseFilterSet(django_filters.FilterSet): return {} # Skip nonstandard lookup expressions - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']: return {} # Choose the lookup expression map based on the filter type diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index 23848724d42..d1451e00344 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,6 +1,6 @@ from django import forms -from netbox.constants import SEARCH_TYPE_HIERARCHY +from netbox.search import SEARCH_TYPE_HIERARCHY from utilities.forms import BootstrapMixin from .base import * diff --git a/netbox/netbox/search.py b/netbox/netbox/search.py new file mode 100644 index 00000000000..ef0c4fd8716 --- /dev/null +++ b/netbox/netbox/search.py @@ -0,0 +1,261 @@ +import circuits.filtersets +import circuits.tables +import dcim.filtersets +import dcim.tables +import ipam.filtersets +import ipam.tables +import tenancy.filtersets +import tenancy.tables +import virtualization.filtersets +import wireless.tables +import wireless.filtersets +import virtualization.tables +from circuits.models import Circuit, ProviderNetwork, Provider +from dcim.models import ( + Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, + VirtualChassis, +) +from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF +from tenancy.models import Contact, Tenant, ContactAssignment +from utilities.utils import count_related +from wireless.models import WirelessLAN, WirelessLink +from virtualization.models import Cluster, VirtualMachine + +CIRCUIT_TYPES = { + 'provider': { + 'queryset': Provider.objects.annotate( + count_circuits=count_related(Circuit, 'provider') + ), + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, + 'url': 'circuits:provider_list', + }, + 'circuit': { + 'queryset': Circuit.objects.prefetch_related( + 'type', 'provider', 'tenant', 'tenant__group', 'terminations__site' + ), + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, + 'url': 'circuits:circuit_list', + }, + 'providernetwork': { + 'queryset': ProviderNetwork.objects.prefetch_related('provider'), + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, + 'url': 'circuits:providernetwork_list', + }, +} + +DCIM_TYPES = { + 'site': { + 'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'), + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, + 'url': 'dcim:site_list', + }, + 'rack': { + 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate( + device_count=count_related(Device, 'rack') + ), + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, + 'url': 'dcim:rack_list', + }, + 'rackreservation': { + 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, + 'url': 'dcim:rackreservation_list', + }, + 'location': { + 'queryset': Location.objects.add_related_count( + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), + Rack, + 'location', + 'rack_count', + cumulative=True + ).prefetch_related('site'), + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, + 'url': 'dcim:location_list', + }, + 'devicetype': { + 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( + instance_count=count_related(Device, 'device_type') + ), + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, + 'url': 'dcim:devicetype_list', + }, + 'device': { + 'queryset': Device.objects.prefetch_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4', + 'primary_ip6', + ), + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, + 'url': 'dcim:device_list', + }, + 'moduletype': { + 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( + instance_count=count_related(Module, 'module_type') + ), + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, + 'url': 'dcim:moduletype_list', + }, + 'module': { + 'queryset': Module.objects.prefetch_related( + 'module_type__manufacturer', 'device', 'module_bay', + ), + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, + 'url': 'dcim:module_list', + }, + 'virtualchassis': { + 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( + member_count=count_related(Device, 'virtual_chassis') + ), + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, + 'url': 'dcim:virtualchassis_list', + }, + 'cable': { + 'queryset': Cable.objects.all(), + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, + 'url': 'dcim:cable_list', + }, + 'powerfeed': { + 'queryset': PowerFeed.objects.all(), + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, + 'url': 'dcim:powerfeed_list', + }, +} + +IPAM_TYPES = { + 'vrf': { + 'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'), + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, + 'url': 'ipam:vrf_list', + }, + 'aggregate': { + 'queryset': Aggregate.objects.prefetch_related('rir'), + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, + 'url': 'ipam:aggregate_list', + }, + 'prefix': { + 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'), + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, + 'url': 'ipam:prefix_list', + }, + 'ipaddress': { + 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'), + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, + 'url': 'ipam:ipaddress_list', + }, + 'vlan': { + 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'), + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, + 'url': 'ipam:vlan_list', + }, + 'asn': { + 'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'), + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, + 'url': 'ipam:asn_list', + }, + 'service': { + 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), + 'filterset': ipam.filtersets.ServiceFilterSet, + 'table': ipam.tables.ServiceTable, + 'url': 'ipam:service_list', + }, +} + +TENANCY_TYPES = { + 'tenant': { + 'queryset': Tenant.objects.prefetch_related('group'), + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, + 'url': 'tenancy:tenant_list', + }, + 'contact': { + 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( + assignment_count=count_related(ContactAssignment, 'contact')), + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, + 'url': 'tenancy:contact_list', + }, +} + +VIRTUALIZATION_TYPES = { + 'cluster': { + 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( + device_count=count_related(Device, 'cluster'), + vm_count=count_related(VirtualMachine, 'cluster') + ), + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, + 'url': 'virtualization:cluster_list', + }, + 'virtualmachine': { + 'queryset': VirtualMachine.objects.prefetch_related( + 'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6', + ), + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, + 'url': 'virtualization:virtualmachine_list', + }, +} + +WIRELESS_TYPES = { + 'wirelesslan': { + 'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate( + interface_count=count_related(Interface, 'wireless_lans') + ), + 'filterset': wireless.filtersets.WirelessLANFilterSet, + 'table': wireless.tables.WirelessLANTable, + 'url': 'wireless:wirelesslan_list', + }, + 'wirelesslink': { + 'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'), + 'filterset': wireless.filtersets.WirelessLinkFilterSet, + 'table': wireless.tables.WirelessLinkTable, + 'url': 'wireless:wirelesslink_list', + }, +} + +SEARCH_TYPE_HIERARCHY = { + 'Circuits': CIRCUIT_TYPES, + 'DCIM': DCIM_TYPES, + 'IPAM': IPAM_TYPES, + 'Tenancy': TENANCY_TYPES, + 'Virtualization': VIRTUALIZATION_TYPES, + 'Wireless': WIRELESS_TYPES, +} + + +def build_search_types(): + result = dict() + + for app_types in SEARCH_TYPE_HIERARCHY.values(): + for name, items in app_types.items(): + result[name] = items + + return result + + +SEARCH_TYPES = build_search_types() diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b9d7e20b9d1..09477158161 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.6' +VERSION = '3.2.7' # Hostname HOSTNAME = platform.node() @@ -476,13 +476,6 @@ if SENTRY_ENABLED: # Django social auth # -# Load all SOCIAL_AUTH_* settings from the user configuration -for param in dir(configuration): - if param.startswith('SOCIAL_AUTH_'): - globals()[param] = getattr(configuration, param) - -SOCIAL_AUTH_JSONFIELD_ENABLED = True - SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', @@ -496,6 +489,14 @@ SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.user.user_details', ) +# Load all SOCIAL_AUTH_* settings from the user configuration +for param in dir(configuration): + if param.startswith('SOCIAL_AUTH_'): + globals()[param] = getattr(configuration, param) + +# Force usage of PostgreSQL's JSONB field for extra data +SOCIAL_AUTH_JSONFIELD_ENABLED = True + # # Django Prometheus diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index f159ee63738..204fce469b0 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -22,8 +22,9 @@ from dcim.models import ( from extras.models import ObjectChange from extras.tables import ObjectChangeTable from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF -from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES +from netbox.constants import SEARCH_MAX_RESULTS from netbox.forms import SearchForm +from netbox.search import SEARCH_TYPES from tenancy.models import Tenant from virtualization.models import Cluster, VirtualMachine from wireless.models import WirelessLAN, WirelessLink diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index b611079e17d..9ea2e5c7c51 100644 --- a/netbox/project-static/dist/netbox.js +++ b/netbox/project-static/dist/netbox.js @@ -1,12 +1,12 @@ -(()=>{var p_=Object.create;var cs=Object.defineProperty,m_=Object.defineProperties,g_=Object.getOwnPropertyDescriptor,v_=Object.getOwnPropertyDescriptors,b_=Object.getOwnPropertyNames,Zf=Object.getOwnPropertySymbols,y_=Object.getPrototypeOf,ed=Object.prototype.hasOwnProperty,E_=Object.prototype.propertyIsEnumerable;var Ql=(tn,en,nn)=>en in tn?cs(tn,en,{enumerable:!0,configurable:!0,writable:!0,value:nn}):tn[en]=nn,Jn=(tn,en)=>{for(var nn in en||(en={}))ed.call(en,nn)&&Ql(tn,nn,en[nn]);if(Zf)for(var nn of Zf(en))E_.call(en,nn)&&Ql(tn,nn,en[nn]);return tn},ua=(tn,en)=>m_(tn,v_(en)),td=tn=>cs(tn,"__esModule",{value:!0});var An=(tn,en)=>()=>(en||tn((en={exports:{}}).exports,en),en.exports),__=(tn,en)=>{td(tn);for(var nn in en)cs(tn,nn,{get:en[nn],enumerable:!0})},S_=(tn,en,nn)=>{if(en&&typeof en=="object"||typeof en=="function")for(let rn of b_(en))!ed.call(tn,rn)&&rn!=="default"&&cs(tn,rn,{get:()=>en[rn],enumerable:!(nn=g_(en,rn))||nn.enumerable});return tn},Rr=tn=>S_(td(cs(tn!=null?p_(y_(tn)):{},"default",tn&&tn.__esModule&&"default"in tn?{get:()=>tn.default,enumerable:!0}:{value:tn,enumerable:!0})),tn);var ar=(tn,en,nn)=>(Ql(tn,typeof en!="symbol"?en+"":en,nn),nn);var Fr=(tn,en,nn)=>new Promise((rn,on)=>{var an=dn=>{try{cn(nn.next(dn))}catch(fn){on(fn)}},ln=dn=>{try{cn(nn.throw(dn))}catch(fn){on(fn)}},cn=dn=>dn.done?rn(dn.value):Promise.resolve(dn.value).then(an,ln);cn((nn=nn.apply(tn,en)).next())});var Rh=An((exports,module)=>{(function(tn,en){typeof define=="function"&&define.amd?define([],en):tn.htmx=en()})(typeof self!="undefined"?self:exports,function(){return function(){"use strict";var D={onLoad:t,process:rt,on:N,off:I,trigger:lt,ajax:$t,find:w,findAll:S,closest:O,values:function(tn,en){var nn=Ot(tn,en||"post");return nn.values},remove:E,addClass:C,removeClass:R,toggleClass:q,takeClass:L,defineExtension:Qt,removeExtension:er,logAll:b,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:!1,scrollBehavior:"smooth"},parseInterval:h,_:e,createEventSource:function(tn){return new EventSource(tn,{withCredentials:!0})},createWebSocket:function(tn){return new WebSocket(tn,[])},version:"1.6.1"},r=["get","post","put","delete","patch"],n=r.map(function(tn){return"[hx-"+tn+"], [data-hx-"+tn+"]"}).join(", ");function h(tn){if(tn!=null)return tn.slice(-2)=="ms"?parseFloat(tn.slice(0,-2))||void 0:tn.slice(-1)=="s"?parseFloat(tn.slice(0,-1))*1e3||void 0:parseFloat(tn)||void 0}function c(tn,en){return tn.getAttribute&&tn.getAttribute(en)}function s(tn,en){return tn.hasAttribute&&(tn.hasAttribute(en)||tn.hasAttribute("data-"+en))}function F(tn,en){return c(tn,en)||c(tn,"data-"+en)}function l(tn){return tn.parentElement}function P(){return document}function d(tn,en){return en(tn)?tn:l(tn)?d(l(tn),en):null}function X(tn,en){var nn=null;if(d(tn,function(rn){return nn=F(rn,en)}),nn!=="unset")return nn}function v(tn,en){var nn=tn.matches||tn.matchesSelector||tn.msMatchesSelector||tn.mozMatchesSelector||tn.webkitMatchesSelector||tn.oMatchesSelector;return nn&&nn.call(tn,en)}function i(tn){var en=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,nn=en.exec(tn);return nn?nn[1].toLowerCase():""}function o(tn,en){for(var nn=new DOMParser,rn=nn.parseFromString(tn,"text/html"),on=rn.body;en>0;)en--,on=on.firstChild;return on==null&&(on=P().createDocumentFragment()),on}function u(tn){if(D.config.useTemplateFragments){var en=o("
"+tn+"",0);return en.querySelector("template").content}else{var nn=i(tn);switch(nn){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return o("