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 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 494ba0b56..60656bc6d 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/reslug.ts b/netbox/project-static/src/buttons/reslug.ts index 2549bf112..f445854c1 100644 --- a/netbox/project-static/src/buttons/reslug.ts +++ b/netbox/project-static/src/buttons/reslug.ts @@ -38,7 +38,9 @@ export function initReslug(): void { slugLength = Number(slugLengthAttr); } sourceField.addEventListener('blur', () => { - slugField.value = slugify(sourceField.value, slugLength); + if (!slugField.value) { + slugField.value = slugify(sourceField.value, slugLength); + } }); slugButton.addEventListener('click', () => { slugField.value = slugify(sourceField.value, slugLength); diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts index 9e2ae67c4..356a8d51e 100644 --- a/netbox/project-static/src/forms/elements.ts +++ b/netbox/project-static/src/forms/elements.ts @@ -1,32 +1,4 @@ -import { getElements, scrollTo, isTruthy } from '../util'; - -/** - * When editing an object, it is sometimes desirable to customize the form action *without* - * overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want - * to use the `formaction` attribute on that element because it will be included on the form even - * if the button isn't clicked. - * - * @example - * ```html - * - * ``` - * - * @param event Click event. - */ -function handleSubmitWithReturnUrl(event: MouseEvent): void { - const element = event.target as HTMLElement; - if (element.tagName === 'BUTTON') { - const button = element as HTMLButtonElement; - const action = button.getAttribute('return-url'); - const form = button.form; - if (form !== null && isTruthy(action)) { - form.action = action; - form.submit(); - } - } -} +import { getElements, scrollTo } from '../util'; function handleFormSubmit(event: Event, form: HTMLFormElement): void { // Track the names of each invalid field. @@ -57,15 +29,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { } } -/** - * Attach event listeners to form buttons with the `return-url` attribute present. - */ -function initReturnUrlSubmitButtons(): void { - for (const button of getElements('button[return-url]')) { - button.addEventListener('click', handleSubmitWithReturnUrl); - } -} - /** * Attach an event listener to each form's submitter (button[type=submit]). When called, the * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class @@ -82,5 +45,4 @@ export function initFormElements(): void { submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); } } - initReturnUrlSubmitButtons(); } diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 798694f0e..f5b605d58 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -411,7 +411,6 @@ export class APISelect { } finally { this.setOptionStyles(); this.enable(); - this.slim.slim.search.input.focus(); this.base.dispatchEvent(this.loadEvent); } } diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 606e12b5e..9c38a3c72 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -56,17 +56,3 @@ {% render_custom_fields form %} {% endblock %} - -{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #} -{% block buttons %} - Cancel - {% if object.pk %} - - {% else %} - - {% endif %} -{% endblock buttons %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index d6fdfd0e1..a044de660 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -99,14 +99,3 @@ {% endif %} {% endblock %} - -{% block buttons %} - Cancel - {% if object.pk %} - - - {% else %} - - - {% endif %} -{% endblock %} diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 1bcc2db1d..edc5132ce 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -36,8 +36,8 @@ Context: {{ field }} {% endfor %}
- Cancel + Cancel
diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 362644135..cea50eaff 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -118,8 +118,8 @@ Context:
- Cancel + Cancel
diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 1a85c3a21..1d638cb2c 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -44,12 +44,12 @@ Context:
-
- {% if return_url %} - Cancel - {% endif %} - -
+
+ + {% if return_url %} + Cancel + {% endif %} +
{% if fields %} diff --git a/netbox/templates/generic/bulk_remove.html b/netbox/templates/generic/bulk_remove.html index 0bda6adbc..6dc102b5a 100644 --- a/netbox/templates/generic/bulk_remove.html +++ b/netbox/templates/generic/bulk_remove.html @@ -23,8 +23,8 @@ {{ field }} {% endfor %}
- Cancel + Cancel
diff --git a/netbox/templates/generic/bulk_rename.html b/netbox/templates/generic/bulk_rename.html index 134d3df5a..ef6b18cae 100644 --- a/netbox/templates/generic/bulk_rename.html +++ b/netbox/templates/generic/bulk_rename.html @@ -34,11 +34,11 @@
- Cancel {% if '_preview' in request.POST and not form.errors %} {% endif %} + Cancel
diff --git a/netbox/templates/generic/confirmation_form.html b/netbox/templates/generic/confirmation_form.html index c4c15f7e7..e9d3d01aa 100644 --- a/netbox/templates/generic/confirmation_form.html +++ b/netbox/templates/generic/confirmation_form.html @@ -2,33 +2,24 @@ {% load form_helpers %} {% block content %} -
- -
- -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - -
-
{% block confirmation_title %}{% endblock %}
- -
- {% block message %}

Are you sure?

{% endblock %} -
- - - -
- -
- +
+
+
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
{% block confirmation_title %}{% endblock %}
+
+ {% block message %}

Are you sure?

{% endblock %} +
+
- +
+
{% endblock %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 06308e9ef..892c7d2b1 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -94,19 +94,19 @@ Context:
{% block buttons %} - Cancel {% if object.pk %} {% else %} - + {% endif %} + Cancel {% endblock buttons %}
diff --git a/netbox/templates/generic/object_import.html b/netbox/templates/generic/object_import.html index ffa16b4c2..4d54fde61 100644 --- a/netbox/templates/generic/object_import.html +++ b/netbox/templates/generic/object_import.html @@ -5,19 +5,19 @@ {% block title %}{{ obj_type|bettertitle }} Import{% endblock %} {% block content %} -
-
-
- {% csrf_token %} - {% render_form form %} -
- {% if return_url %} - Cancel - {% endif %} - - -
-
-
-
+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + + {% if return_url %} + Cancel + {% endif %} +
+
+
+
{% endblock content %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 496960a64..316900865 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -55,14 +55,3 @@
{% endif %} {% endblock %} - -{% block buttons %} - Cancel - {% if object.pk %} - - - {% else %} - - - {% endif %} -{% endblock %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index b5cce741a..f217fdaf8 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -4,6 +4,7 @@ from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.api import get_serializer_for_model @@ -108,6 +109,6 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_object(self, instance): - serializer = get_serializer_for_model(instance.content_type.model_class(), prefix='Nested') + serializer = get_serializer_for_model(instance.content_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} return serializer(instance.object, context=context).data diff --git a/netbox/users/views.py b/netbox/users/views.py index aabdd6774..1fb2baa62 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -20,7 +20,7 @@ from netbox.authentication import get_auth_backend_display from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm -from .models import Token +from .models import Token, UserConfig from .tables import TokenTable @@ -70,7 +70,13 @@ class LoginView(View): # Authenticate user auth_login(request, form.get_user()) logger.info(f"User {request.user} successfully authenticated") - messages.info(request, "Logged in as {}.".format(request.user)) + messages.info(request, f"Logged in as {request.user}.") + + # Ensure the user has a UserConfig defined. (This should normally be handled by + # create_userconfig() on user creation.) + if not hasattr(request.user, 'config'): + config = get_config() + UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save() return self.redirect_to_next(request, logger) diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 738dc0e00..5a6841286 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -144,6 +144,8 @@ def render_markdown(value): {{ md_source_text|markdown }} """ + if not value: + return '' # Render Markdown html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0b593289b..4cd7da30d 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -471,6 +471,7 @@ class VMInterfaceBulkImportView(generic.BulkImportView): class VMInterfaceBulkEditView(generic.BulkEditView): queryset = VMInterface.objects.all() + filterset = filtersets.VMInterfaceFilterSet table = tables.VMInterfaceTable form = forms.VMInterfaceBulkEditForm @@ -482,6 +483,7 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView): class VMInterfaceBulkDeleteView(generic.BulkDeleteView): queryset = VMInterface.objects.all() + filterset = filtersets.VMInterfaceFilterSet table = tables.VMInterfaceTable