1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into feature

This commit is contained in:
jeremystretch
2022-07-20 12:20:33 -04:00
39 changed files with 418 additions and 444 deletions

View File

@ -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. 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.)

View File

@ -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. * `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. * `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. * `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 ### Default Request Body

View File

@ -1,6 +1,27 @@
# NetBox v3.2 # 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
--- ---

View File

@ -19,6 +19,7 @@ from netbox.api.serializers import (
WritableNestedSerializer, WritableNestedSerializer,
) )
from netbox.config import ConfigItem from netbox.config import ConfigItem
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -57,7 +58,7 @@ class CabledObjectSerializer(serializers.ModelSerializer):
return [] return []
# Return serialized peer termination objects # 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']} context = {'request': self.context['request']}
return serializer(obj.link_peers, context=context, many=True).data 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. Return the appropriate serializer for the type of connected object.
""" """
if endpoints := obj.connected_endpoints: 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']} context = {'request': self.context['request']}
return serializer(endpoints, many=True, context=context).data return serializer(endpoints, many=True, context=context).data
@ -572,7 +573,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
def get_component(self, obj): def get_component(self, obj):
if obj.component is None: if obj.component is None:
return 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']} context = {'request': self.context['request']}
return serializer(obj.component, context=context).data return serializer(obj.component, context=context).data
@ -968,7 +969,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
def get_component(self, obj): def get_component(self, obj):
if obj.component is None: if obj.component is None:
return 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']} context = {'request': self.context['request']}
return serializer(obj.component, context=context).data return serializer(obj.component, context=context).data
@ -1037,7 +1038,7 @@ class CableTerminationSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination(self, obj): 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']} context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data return serializer(obj.termination, context=context).data
@ -1053,7 +1054,7 @@ class CablePathSerializer(serializers.ModelSerializer):
def get_path(self, obj): def get_path(self, obj):
ret = [] ret = []
for nodes in obj.path_objects: 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']} context = {'request': self.context['request']}
ret.append(serializer(nodes, context=context, many=True).data) ret.append(serializer(nodes, context=context, many=True).data)
return ret return ret

View File

@ -24,6 +24,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -65,7 +66,7 @@ class PathEndpointMixin(object):
# Serialize path objects, iterating over each three-tuple in the path # Serialize path objects, iterating over each three-tuple in the path
for near_end, cable, far_end in obj.trace(): for near_end, cable, far_end in obj.trace():
if near_end is not None: 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 near_end = serializer_a(near_end, many=True, context={'request': request}).data
else: else:
# Path is split; stop here # Path is split; stop here
@ -73,7 +74,7 @@ class PathEndpointMixin(object):
if cable is not None: if cable is not None:
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
if far_end is not None: 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 far_end = serializer_b(far_end, many=True, context={'request': request}).data
path.append((near_end, cable, far_end)) path.append((near_end, cable, far_end))

View File

@ -312,7 +312,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
serial = django_filters.CharFilter( serial = MultiValueCharFilter(
lookup_expr='iexact' lookup_expr='iexact'
) )
@ -1007,10 +1007,13 @@ class ModuleFilterSet(NetBoxModelFilterSet):
queryset=Device.objects.all(), queryset=Device.objects.all(),
label='Device (ID)', label='Device (ID)',
) )
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
class Meta: class Meta:
model = Module model = Module
fields = ['id', 'serial', 'asset_tag'] fields = ['id', 'asset_tag']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1411,7 +1414,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
) )
component_type = ContentTypeFilter() component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter() component_id = MultiValueNumberFilter()
serial = django_filters.CharFilter( serial = MultiValueCharFilter(
lookup_expr='iexact' lookup_expr='iexact'
) )

View File

@ -998,8 +998,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
) )
speed = forms.IntegerField( speed = forms.IntegerField(
required=False, required=False,
label='Select Speed', label='Speed',
widget=SelectSpeedWidget(attrs={'readonly': None}) widget=SelectSpeedWidget()
) )
duplex = MultipleChoiceField( duplex = MultipleChoiceField(
choices=InterfaceDuplexChoices, choices=InterfaceDuplexChoices,

View File

@ -163,8 +163,9 @@ class RackElevationSVG:
# Embed device type image if provided # Embed device type image if provided
if self.include_images and image: if self.include_images and image:
url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
image = Image( image = Image(
href=f'{self.base_url}{image.url}', href=url,
insert=coords, insert=coords,
size=size, size=size,
class_=f'device-image{css_extra}' class_=f'device-image{css_extra}'

View File

@ -498,10 +498,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self): def test_serial(self):
params = {'serial': 'ABC'} params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': 'abc'} params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self): def test_tenant(self):
tenants = Tenant.objects.all()[:2] tenants = Tenant.objects.all()[:2]
@ -1864,7 +1864,9 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_serial(self): 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asset_tag(self): def test_asset_tag(self):
@ -3520,10 +3522,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self): def test_serial(self):
params = {'serial': 'ABC'} params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': 'abc'} params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_component_type(self): def test_component_type(self):
params = {'component_type': 'dcim.interface'} params = {'component_type': 'dcim.interface'}

View File

@ -3,6 +3,7 @@ from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField 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(): for cf in self._get_custom_fields():
value = cf.deserialize(obj.get(cf.name)) value = cf.deserialize(obj.get(cf.name))
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: 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 value = serializer(value, context=self.parent.context).data
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 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 value = serializer(value, many=True, context=self.parent.context).data
data[cf.name] = value data[cf.name] = value

View File

@ -15,6 +15,7 @@ from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer 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.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
@ -193,7 +194,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_parent(self, obj): 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 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) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance): 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']} context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data return serializer(instance.assigned_object, context=context).data
@ -469,7 +470,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
return None return None
try: 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: except SerializerNotFound:
return obj.object_repr return obj.object_repr
context = { context = {

View File

@ -10,6 +10,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import * from ipam.models import *
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@ -148,7 +149,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
def get_interface(self, obj): def get_interface(self, obj):
if obj.interface is None: if obj.interface is None:
return 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']} context = {'request': self.context['request']}
return serializer(obj.interface, context=context).data return serializer(obj.interface, context=context).data
@ -194,7 +195,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
def get_scope(self, obj): def get_scope(self, obj):
if obj.scope_id is None: if obj.scope_id is None:
return 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']} context = {'request': self.context['request']}
return serializer(obj.scope, context=context).data return serializer(obj.scope, context=context).data
@ -378,7 +379,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
def get_assigned_object(self, obj): def get_assigned_object(self, obj):
if obj.assigned_object is None: if obj.assigned_object is None:
return 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']} context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data return serializer(obj.assigned_object, context=context).data
@ -485,6 +486,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance): 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']} context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data return serializer(instance.assigned_object, context=context).data

View File

@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet
from extras.models import ExportTemplate from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .mixins import * from .mixins import *
@ -61,7 +62,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
if self.brief: if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer") logger.debug("Request is for 'brief' format; initializing nested serializer")
try: 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}") logger.debug(f"Using serializer {serializer}")
return serializer return serializer
except SerializerNotFound: except SerializerNotFound:

View File

@ -1,256 +1,5 @@
from collections import OrderedDict # Prefix for nested serializers
from typing import Dict NESTED_SERIALIZER_PREFIX = 'Nested'
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
# Max results per object type
SEARCH_MAX_RESULTS = 15 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()

View File

@ -125,7 +125,7 @@ class BaseFilterSet(django_filters.FilterSet):
return {} return {}
# Skip nonstandard lookup expressions # 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 {} return {}
# Choose the lookup expression map based on the filter type # Choose the lookup expression map based on the filter type

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from netbox.constants import SEARCH_TYPE_HIERARCHY from netbox.search import SEARCH_TYPE_HIERARCHY
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from .base import * from .base import *

261
netbox/netbox/search.py Normal file
View File

@ -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()

View File

@ -478,13 +478,6 @@ if SENTRY_ENABLED:
# Django social auth # 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_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_uid',
@ -498,6 +491,14 @@ SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.user.user_details', '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 # Django Prometheus

View File

@ -21,8 +21,9 @@ from dcim.models import (
from extras.models import ObjectChange from extras.models import ObjectChange
from extras.tables import ObjectChangeTable from extras.tables import ObjectChangeTable
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF 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.forms import SearchForm
from netbox.search import SEARCH_TYPES
from tenancy.models import Tenant from tenancy.models import Tenant
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -38,7 +38,9 @@ export function initReslug(): void {
slugLength = Number(slugLengthAttr); slugLength = Number(slugLengthAttr);
} }
sourceField.addEventListener('blur', () => { sourceField.addEventListener('blur', () => {
if (!slugField.value) {
slugField.value = slugify(sourceField.value, slugLength); slugField.value = slugify(sourceField.value, slugLength);
}
}); });
slugButton.addEventListener('click', () => { slugButton.addEventListener('click', () => {
slugField.value = slugify(sourceField.value, slugLength); slugField.value = slugify(sourceField.value, slugLength);

View File

@ -1,32 +1,4 @@
import { getElements, scrollTo, isTruthy } from '../util'; import { getElements, scrollTo } 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
* <button type="button" return-url="/special-url/">
* Save & Continue
* </button>
* ```
*
* @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();
}
}
}
function handleFormSubmit(event: Event, form: HTMLFormElement): void { function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Track the names of each invalid field. // 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<HTMLButtonElement>('button[return-url]')) {
button.addEventListener('click', handleSubmitWithReturnUrl);
}
}
/** /**
* Attach an event listener to each form's submitter (button[type=submit]). When called, the * 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 * 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)); submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
} }
} }
initReturnUrlSubmitButtons();
} }

View File

@ -411,7 +411,6 @@ export class APISelect {
} finally { } finally {
this.setOptionStyles(); this.setOptionStyles();
this.enable(); this.enable();
this.slim.slim.search.input.focus();
this.base.dispatchEvent(this.loadEvent); this.base.dispatchEvent(this.loadEvent);
} }
} }

View File

@ -56,17 +56,3 @@
{% render_custom_fields form %} {% render_custom_fields form %}
</div> </div>
{% endblock %} {% endblock %}
{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
{% block buttons %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">
Save
</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">
Create
</button>
{% endif %}
{% endblock buttons %}

View File

@ -99,14 +99,3 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if object.pk %}
<button type="button" return-url="?return_url={% url 'dcim:interface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
{% endblock %}

View File

@ -36,8 +36,8 @@ Context:
{{ field }} {{ field }}
{% endfor %} {% endfor %}
<div class="text-end"> <div class="text-end">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-danger">Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button> <button type="submit" name="_confirm" class="btn btn-danger">Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -118,8 +118,8 @@ Context:
</div> </div>
<div class="text-end"> <div class="text-end">
<a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
<button type="submit" name="_apply" class="btn btn-sm btn-primary">Apply</button> <button type="submit" name="_apply" class="btn btn-sm btn-primary">Apply</button>
<a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -45,10 +45,10 @@ Context:
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %} {% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a> <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary">Submit</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -23,8 +23,8 @@
{{ field }} {{ field }}
{% endfor %} {% endfor %}
<div class="text-center"> <div class="text-center">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button> <button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -34,11 +34,11 @@
</div> </div>
</div> </div>
<div class="col col-md-12 my-3 text-end"> <div class="col col-md-12 my-3 text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_preview" class="btn btn-primary">Preview</button> <button type="submit" name="_preview" class="btn btn-primary">Preview</button>
{% if '_preview' in request.POST and not form.errors %} {% if '_preview' in request.POST and not form.errors %}
<button type="submit" name="_apply" class="btn btn-primary">Apply</button> <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -3,32 +3,23 @@
{% block content %} {% block content %}
<div class="row mt-5"> <div class="row mt-5">
<div class="col col-md-6 offset-md-3"> <div class="col col-md-6 offset-md-3">
<form action="" method="post" class="form"> <form action="" method="post" class="form">
{% csrf_token %} {% csrf_token %}
{% for field in form.hidden_fields %} {% for field in form.hidden_fields %}
{{ field }} {{ field }}
{% endfor %} {% endfor %}
<div class="card border-danger"> <div class="card border-danger">
<h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5> <h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5>
<div class="card-body"> <div class="card-body">
{% block message %}<p>Are you sure?</p>{% endblock %} {% block message %}<p>Are you sure?</p>{% endblock %}
</div> </div>
<div class="card-footer text-end"> <div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button> <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -94,19 +94,19 @@ Context:
<div class="text-end my-3"> <div class="text-end my-3">
{% block buttons %} {% block buttons %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% if object.pk %} {% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary"> <button type="submit" name="_update" class="btn btn-primary">
Save Save
</button> </button>
{% else %} {% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">
Create & Add Another
</button>
<button type="submit" name="_create" class="btn btn-primary"> <button type="submit" name="_create" class="btn btn-primary">
Create Create
</button> </button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">
Create & Add Another
</button>
{% endif %} {% endif %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% endblock buttons %} {% endblock buttons %}
</div> </div>
</form> </form>

View File

@ -11,11 +11,11 @@
{% csrf_token %} {% csrf_token %}
{% render_form form %} {% render_form form %}
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<button type="submit" name="_create" class="btn btn-primary">Submit</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
{% if return_url %} {% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a> <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %} {% endif %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
<button type="submit" name="_create" class="btn btn-primary">Submit</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -55,14 +55,3 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if object.pk %}
<button type="button" return-url="?return_url={% url 'virtualization:vminterface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
{% endblock %}

View File

@ -4,6 +4,7 @@ from rest_framework import serializers
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.choices import ContactPriorityChoices from tenancy.choices import ContactPriorityChoices
from tenancy.models import * from tenancy.models import *
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -108,6 +109,6 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_object(self, instance): 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']} context = {'request': self.context['request']}
return serializer(instance.object, context=context).data return serializer(instance.object, context=context).data

View File

@ -20,7 +20,7 @@ from netbox.authentication import get_auth_backend_display
from netbox.config import get_config from netbox.config import get_config
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
from .models import Token from .models import Token, UserConfig
from .tables import TokenTable from .tables import TokenTable
@ -70,7 +70,13 @@ class LoginView(View):
# Authenticate user # Authenticate user
auth_login(request, form.get_user()) auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated") 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) return self.redirect_to_next(request, logger)

View File

@ -144,6 +144,8 @@ def render_markdown(value):
{{ md_source_text|markdown }} {{ md_source_text|markdown }}
""" """
if not value:
return ''
# Render Markdown # Render Markdown
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])

View File

@ -471,6 +471,7 @@ class VMInterfaceBulkImportView(generic.BulkImportView):
class VMInterfaceBulkEditView(generic.BulkEditView): class VMInterfaceBulkEditView(generic.BulkEditView):
queryset = VMInterface.objects.all() queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable table = tables.VMInterfaceTable
form = forms.VMInterfaceBulkEditForm form = forms.VMInterfaceBulkEditForm
@ -482,6 +483,7 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
class VMInterfaceBulkDeleteView(generic.BulkDeleteView): class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
queryset = VMInterface.objects.all() queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable table = tables.VMInterfaceTable