diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index b405ed09a..fca9eab5e 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 d0137938d..9f64401ae 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 1ba6235b8..c36344912 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,27 @@ # NetBox v3.2 -## v3.2.7 (FUTURE) +## v3.2.8 (FUTURE) + +--- + +## 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 --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cc5c87a8a..5f30b7385 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -19,6 +19,7 @@ from netbox.api.serializers import ( 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 @@ -57,7 +58,7 @@ class CabledObjectSerializer(serializers.ModelSerializer): return [] # Return serialized peer termination objects - serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested') + serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.link_peers, context=context, many=True).data @@ -84,7 +85,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer): Return the appropriate serializer for the type of connected object. """ if endpoints := obj.connected_endpoints: - serializer = get_serializer_for_model(endpoints[0], prefix='Nested') + serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(endpoints, many=True, context=context).data @@ -572,7 +573,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 @@ -968,7 +969,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 @@ -1037,7 +1038,7 @@ class CableTerminationSerializer(NetBoxModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_termination(self, obj): - serializer = get_serializer_for_model(obj.termination, prefix='Nested') + serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(obj.termination, context=context).data @@ -1053,7 +1054,7 @@ class CablePathSerializer(serializers.ModelSerializer): def get_path(self, obj): ret = [] for nodes in obj.path_objects: - serializer = get_serializer_for_model(nodes[0], prefix='Nested') + serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} ret.append(serializer(nodes, context=context, many=True).data) return ret diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index cff337fcf..59445d97b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -24,6 +24,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 @@ -65,7 +66,7 @@ class PathEndpointMixin(object): # Serialize path objects, iterating over each three-tuple in the path for near_end, cable, far_end in obj.trace(): if near_end is not None: - serializer_a = get_serializer_for_model(near_end[0], prefix='Nested') + serializer_a = get_serializer_for_model(near_end[0], prefix=NESTED_SERIALIZER_PREFIX) near_end = serializer_a(near_end, many=True, context={'request': request}).data else: # Path is split; stop here @@ -73,7 +74,7 @@ class PathEndpointMixin(object): if cable is not None: cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data if far_end is not None: - serializer_b = get_serializer_for_model(far_end[0], prefix='Nested') + serializer_b = get_serializer_for_model(far_end[0], prefix=NESTED_SERIALIZER_PREFIX) far_end = serializer_b(far_end, many=True, context={'request': request}).data path.append((near_end, cable, far_end)) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f55b3301e..4bdc525a5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -312,7 +312,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe to_field_name='slug', label='Role (slug)', ) - serial = django_filters.CharFilter( + serial = MultiValueCharFilter( lookup_expr='iexact' ) @@ -1007,10 +1007,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(): @@ -1411,7 +1414,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 bd64e02b4..8d2e24c9c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -998,8 +998,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/racks.py b/netbox/dcim/svg/racks.py index f228c416b..28527498f 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -163,8 +163,9 @@ class RackElevationSVG: # Embed device type image if provided if self.include_images and image: + url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url image = Image( - href=f'{self.base_url}{image.url}', + href=url, insert=coords, size=size, class_=f'device-image{css_extra}' diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 24c45dd7f..21daa32c1 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -498,10 +498,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] @@ -1864,7 +1864,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): @@ -3520,10 +3522,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/extras/api/customfields.py b/netbox/extras/api/customfields.py index fd6e1f550..b7fd1e129 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 0688f6d76..69792e88c 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.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField 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 @@ -193,7 +194,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 @@ -243,7 +244,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 @@ -469,7 +470,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 32fa4e6af..b3a3589fd 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.fields 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 @@ -148,7 +149,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 @@ -194,7 +195,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 @@ -378,7 +379,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 @@ -485,6 +486,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested') + serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(instance.assigned_object, context=context).data diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 2d3780bde..c50ad9ca6 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 utilities.exceptions import AbortRequest from .mixins import * @@ -61,7 +62,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 cc04e9aa8..776938a97 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 1a72d8159..f509afa5b 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 23848724d..d1451e003 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 000000000..ef0c4fd87 --- /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 e8d414b44..e0ec8e1ec 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -478,13 +478,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', @@ -498,6 +491,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 666e3d28a..bc1f0e2ca 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -21,8 +21,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 b611079e1..9ea2e5c7c 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("