From 812ce8471a17656a82f6599e018e5b5319febfaf Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 7 Nov 2024 07:28:02 -0800 Subject: [PATCH 1/6] 10711 Add Scope to WirelessLAN (#17877) * 7699 Add Scope to Cluster * 7699 Serializer * 7699 filterset * 7699 bulk_edit * 7699 bulk_import * 7699 model_form * 7699 graphql, tables * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fix tests * 7699 fix graphql tests for clusters reference * 7699 fix dcim tests * 7699 fix ipam tests * 7699 fix tests * 7699 use mixin for model * 7699 change mixin name * 7699 scope form * 7699 scope form * 7699 scoped form, fitlerset * 7699 review changes * 7699 move ScopedFilterset * 7699 move CachedScopeMixin * 7699 review changes * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 7699 review changes * 7699 refactor mixins * 7699 _sitegroup -> _site_group * 7699 update docstring * fix model * remove old constants, update filtersets * 10711 fix GraphQL * 10711 fix API * 10711 add tests * 10711 review changes * 10711 add tests * 10711 add scope to detail template * 10711 add api test * Extend CSV test data --------- Co-authored-by: Jeremy Stretch --- docs/models/wireless/wirelesslan.md | 4 + netbox/templates/wireless/wirelesslan.html | 8 ++ .../wireless/api/serializers_/wirelesslans.py | 28 ++++++- netbox/wireless/filtersets.py | 5 +- netbox/wireless/forms/bulk_edit.py | 6 +- netbox/wireless/forms/bulk_import.py | 12 ++- netbox/wireless/forms/filtersets.py | 27 +++++++ netbox/wireless/forms/model_forms.py | 6 +- netbox/wireless/graphql/types.py | 13 +++- ...__location_wirelesslan__region_and_more.py | 77 ++++++++++++++++++ netbox/wireless/models.py | 5 +- netbox/wireless/tables/wirelesslan.py | 9 ++- netbox/wireless/tests/test_api.py | 10 ++- netbox/wireless/tests/test_filtersets.py | 78 +++++++++++++++++-- netbox/wireless/tests/test_views.py | 19 +++-- 15 files changed, 277 insertions(+), 30 deletions(-) create mode 100644 netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 0f50fa75f..a448c42a2 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -43,3 +43,7 @@ The security cipher used to apply wireless authentication. Options include: ### Pre-Shared Key The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. + +### Scope + +The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated. diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 493c36132..54473ea54 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -22,6 +22,14 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Scope" %} + {% if object.scope %} + {{ object.scope|linkify }} ({% trans object.scope_type.name %}) + {% else %} + {{ ''|placeholder }} + {% endif %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 6c5deeb26..637089277 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -1,9 +1,13 @@ from rest_framework import serializers +from dcim.constants import LOCATION_SCOPE_TYPES +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field from ipam.api.serializers_.vlans import VLANSerializer -from netbox.api.fields import ChoiceField +from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model from wireless.choices import * from wireless.models import WirelessLAN, WirelessLANGroup from .nested import NestedWirelessLANGroupSerializer @@ -34,12 +38,30 @@ class WirelessLANSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=LOCATION_SCOPE_TYPES + ), + allow_null=True, + required=False, + default=None + ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) + scope = serializers.SerializerMethodField(read_only=True) class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', - 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type', 'scope_id', 'scope', + 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'ssid', 'description') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope) + context = {'request': self.context['request']} + return serializer(obj.scope, nested=True, context=context).data diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 537b2ec5c..5a4195e6c 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,6 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices +from dcim.filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -43,7 +44,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -74,7 +75,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = WirelessLAN - fields = ('id', 'ssid', 'auth_psk', 'description') + fields = ('id', 'ssid', 'auth_psk', 'scope_id', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c8b378104..5cd3a157a 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.forms.mixins import ScopedBulkEditForm from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelBulkEditForm @@ -39,7 +40,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('parent', 'description') -class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): +class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(WirelessLANStatusChoices), @@ -89,10 +90,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'scope', 'comments', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index cff3e49af..5bf2d7dcd 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,12 +1,13 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.forms.mixins import ScopedImportForm from dcim.models import Interface from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -32,7 +33,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class WirelessLANImportForm(NetBoxModelImportForm): +class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): group = CSVModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -75,9 +76,12 @@ class WirelessLANImportForm(NetBoxModelImportForm): class Meta: model = WirelessLAN fields = ( - 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', 'tags', + 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', 'scope_id', + 'description', 'comments', 'tags', ) + labels = { + 'scope_id': _('Scope ID'), + } class WirelessLinkImportForm(NetBoxModelImportForm): diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 6439a2516..f62a3be06 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.models import Location, Region, Site, SiteGroup from netbox.choices import * from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm @@ -33,6 +34,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('ssid', 'group_id', 'status', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -65,6 +67,31 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Pre-shared key'), required=False ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site') + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 7c2594271..877324b8c 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -2,6 +2,7 @@ from django.forms import PasswordInput from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Location, Site +from dcim.forms.mixins import ScopedForm from ipam.models import VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -35,7 +36,7 @@ class WirelessLANGroupForm(NetBoxModelForm): ] -class WirelessLANForm(TenancyForm, NetBoxModelForm): +class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -51,6 +52,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -59,7 +61,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): model = WirelessLAN fields = [ 'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', - 'description', 'comments', 'tags', + 'scope_type', 'description', 'comments', 'tags', ] widgets = { 'auth_psk': PasswordInput( diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index b24525fbe..aa44e9b9f 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -28,7 +28,7 @@ class WirelessLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.WirelessLAN, - fields='__all__', + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=WirelessLANFilter ) class WirelessLANType(NetBoxObjectType): @@ -38,6 +38,15 @@ class WirelessLANType(NetBoxObjectType): interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["LocationType", strawberry.lazy('dcim.graphql.types')], + Annotated["RegionType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("WirelessLANScopeType")] | None: + return self.scope + @strawberry_django.type( models.WirelessLink, diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py new file mode 100644 index 000000000..ea4470641 --- /dev/null +++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 5.0.9 on 2024-11-04 16:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0196_qinq_svlan'), + ('wireless', '0010_charfield_null_choices'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='_%(class)ss', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='wirelesslan', + name='_region', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='_%(class)ss', + to='dcim.region', + ), + ), + migrations.AddField( + model_name='wirelesslan', + name='_site', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='_%(class)ss', + to='dcim.site', + ), + ), + migrations.AddField( + model_name='wirelesslan', + name='_site_group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='_%(class)ss', + to='dcim.sitegroup', + ), + ), + migrations.AddField( + model_name='wirelesslan', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='wirelesslan', + name='scope_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='contenttypes.contenttype', + ), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 88c60d494..d78c893a6 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES +from dcim.models.mixins import CachedScopeMixin from netbox.models import NestedGroupModel, PrimaryModel from netbox.models.mixins import DistanceMixin from .choices import * @@ -71,7 +72,7 @@ class WirelessLANGroup(NestedGroupModel): verbose_name_plural = _('wireless LAN groups') -class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -107,7 +108,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): null=True ) - clone_fields = ('ssid', 'group', 'tenant', 'description') + clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description') class Meta: ordering = ('ssid', 'pk') diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 87ad4ac51..40f52f8a5 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -51,6 +51,13 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), + linkify=True + ) interface_count = tables.Column( verbose_name=_('Interfaces') ) @@ -65,7 +72,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', - 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', + 'auth_cipher', 'auth_psk', 'scope', 'scope_type', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count') diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 4b7545888..f768eafaf 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from dcim.choices import InterfaceTypeChoices -from dcim.models import Interface +from dcim.models import Interface, Site from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, create_test_device from wireless.choices import * @@ -53,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -94,6 +100,8 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, + 'scope_type': 'dcim.site', + 'scope_id': sites[1].pk, }, ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 5c932928c..76ef4e220 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from dcim.choices import InterfaceTypeChoices, LinkStatusChoices -from dcim.models import Interface +from dcim.models import Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN from netbox.choices import DistanceUnitChoices from tenancy.models import Tenant @@ -110,6 +110,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) VLAN.objects.bulk_create(vlans) + regions = ( + Region(name='Test Region 1', slug='test-region-1'), + Region(name='Test Region 2', slug='test-region-2'), + Region(name='Test Region 3', slug='test-region-3'), + ) + for r in regions: + r.save() + + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + + sites = ( + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + ) + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[2]), + ) + for location in locations: + location.save() + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -127,7 +157,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1', - description='foobar1' + description='foobar1', + scope=sites[0] ), WirelessLAN( ssid='WLAN2', @@ -138,7 +169,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2', - description='foobar2' + description='foobar2', + scope=locations[0] ), WirelessLAN( ssid='WLAN3', @@ -149,12 +181,14 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3', - description='foobar3' + description='foobar3', + scope=locations[1] ), ) - WirelessLAN.objects.bulk_create(wireless_lans) + for wireless_lan in wireless_lans: + wireless_lan.save() - device = create_test_device('Device 1') + device = create_test_device('Device 1', site=sites[0]) interfaces = ( Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N), Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N), @@ -217,6 +251,38 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:1] + params = {'location_id': [locations[0].pk,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'location': [locations[0].slug,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_scope_type(self): + params = {'scope_type': 'dcim.location'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = WirelessLink.objects.all() diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index d28d9fde3..713ba81d7 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -1,7 +1,8 @@ +from django.contrib.contenttypes.models import ContentType from wireless.choices import * from wireless.models import * from dcim.choices import InterfaceTypeChoices, LinkStatusChoices -from dcim.models import Interface +from dcim.models import Interface, Site from netbox.choices import DistanceUnitChoices from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -56,6 +57,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -98,15 +105,17 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ssid': 'WLAN2', 'group': groups[1].pk, 'status': WirelessLANStatusChoices.STATUS_DISABLED, + 'scope_type': ContentType.objects.get_for_model(Site).pk, + 'scope': sites[1].pk, 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,ssid,status,tenant", - f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}", - f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}", - f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}", + "group,ssid,status,tenant,scope_type,scope_id", + f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name},,", + f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name},dcim.site,{sites[0].pk}", + f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name},dcim.site,{sites[1].pk}", ) cls.csv_update_data = ( From a1830488910ff58c8fb05b43a7f3bccd685143ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Nov 2024 10:24:15 -0500 Subject: [PATCH 2/6] Closes #17951: Extend Ruff ruleset --- .../0047_circuittermination__termination.py | 1 + netbox/circuits/tests/test_views.py | 2 +- netbox/extras/forms/bulk_import.py | 2 +- .../extras/management/commands/runscript.py | 2 +- netbox/extras/migrations/0109_script_model.py | 4 ++-- netbox/ipam/lookups.py | 4 ++-- netbox/ipam/tests/test_ordering.py | 6 +++--- netbox/netbox/authentication/__init__.py | 2 +- netbox/netbox/tests/test_plugins.py | 1 - netbox/netbox/views/errors.py | 2 +- netbox/utilities/error_handlers.py | 2 +- netbox/utilities/tests/test_counters.py | 2 +- .../api/serializers_/clusters.py | 2 -- netbox/virtualization/graphql/types.py | 2 +- .../migrations/0044_cluster_scope.py | 20 +++++++++---------- ruff.toml | 2 ++ 16 files changed, 28 insertions(+), 28 deletions(-) diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index cb2c9ca07..0cf2b424f 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -21,6 +21,7 @@ def copy_site_assignments(apps, schema_editor): termination_id=models.F('provider_network_id') ) + class Migration(migrations.Migration): dependencies = [ diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index a87e327af..f6c626443 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -341,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class TestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 258df8264..655a5d6ca 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -223,7 +223,7 @@ class EventRuleImportForm(NetBoxModelImportForm): from extras.scripts import get_module_and_script module_name, script_name = action_object.split('.', 1) try: - module, script = get_module_and_script(module_name, script_name) + script = get_module_and_script(module_name, script_name)[1] except ObjectDoesNotExist: raise forms.ValidationError(_("Script {name} not found").format(name=action_object)) self.instance.action_object = script diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d5fb435ad..847d89396 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -38,7 +38,7 @@ class Command(BaseCommand): data = {} module_name, script_name = script.split('.', 1) - module, script_obj = get_module_and_script(module_name, script_name) + script_obj = get_module_and_script(module_name, script_name)[1] script = script_obj.python_class # Take user from command line if provided and exists, other diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py index 6bfd2c14c..2fa0bf8aa 100644 --- a/netbox/extras/migrations/0109_script_model.py +++ b/netbox/extras/migrations/0109_script_model.py @@ -30,7 +30,7 @@ def get_python_name(scriptmodule): """ Return the Python name of a ScriptModule's file on disk. """ - path, filename = os.path.split(scriptmodule.file_path) + filename = os.path.split(scriptmodule.file_path)[0] return os.path.splitext(filename)[0] @@ -128,7 +128,7 @@ def update_event_rules(apps, schema_editor): for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct): name = eventrule.action_parameters.get('script_name') - obj, created = Script.objects.get_or_create( + obj, __ = Script.objects.get_or_create( module_id=eventrule.action_object_id, name=name, defaults={'is_executable': False} diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index c6abb5a26..c493b7876 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -108,8 +108,8 @@ class NetIn(Lookup): return self.rhs def as_sql(self, qn, connection): - lhs, lhs_params = self.process_lhs(qn, connection) - rhs, rhs_params = self.process_rhs(qn, connection) + lhs = self.process_lhs(qn, connection)[0] + rhs_params = self.process_rhs(qn, connection)[1] with_mask, without_mask = [], [] for address in rhs_params[0]: if '/' in address: diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 8d69af847..fea6b55e2 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -42,7 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase): """ This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs """ - vrf1, vrf2, vrf3 = list(VRF.objects.all()) + vrf1, vrf2 = VRF.objects.all()[:2] prefixes = ( Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')), @@ -106,7 +106,7 @@ class PrefixOrderingTestCase(OrderingTestBase): VRF A:10.1.1.0/24 None: 192.168.0.0/16 """ - vrf1, vrf2, vrf3 = list(VRF.objects.all()) + vrf1 = VRF.objects.first() prefixes = [ Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')), @@ -130,7 +130,7 @@ class IPAddressOrderingTestCase(OrderingTestBase): """ This function tests ordering with the inclusion of vrfs """ - vrf1, vrf2, vrf3 = list(VRF.objects.all()) + vrf1, vrf2 = VRF.objects.all()[:2] addresses = ( IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')), diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index 7c2df4200..83f699e42 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -107,7 +107,7 @@ class ObjectPermissionMixin: return perms def has_perm(self, user_obj, perm, obj=None): - app_label, action, model_name = resolve_permission(perm) + app_label, __, model_name = resolve_permission(perm) # Superusers implicitly have all permissions if user_obj.is_active and user_obj.is_superuser: diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index db82d0a75..264c8e6f9 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -213,7 +213,6 @@ class PluginTest(TestCase): self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) - def test_events_pipeline(self): """ Check that events pipeline is registered. diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index 9e8ed5a3a..5872a59cd 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -49,7 +49,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): template = loader.get_template(template_name) except TemplateDoesNotExist: return HttpResponseServerError('

Server Error (500)

', content_type='text/html') - type_, error, traceback = sys.exc_info() + type_, error = sys.exc_info()[:2] return HttpResponseServerError(template.render({ 'error': error, diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 5d2a46424..397098ded 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -49,7 +49,7 @@ def handle_rest_api_exception(request, *args, **kwargs): """ Handle exceptions and return a useful error message for REST API requests. """ - type_, error, traceback = sys.exc_info() + type_, error = sys.exc_info()[:2] data = { 'error': str(error), 'exception': type_.__name__, diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 45823065e..668965e8a 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -83,7 +83,7 @@ class CountersTest(TestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_mptt_child_delete(self): - device1, device2 = Device.objects.all() + device1 = Device.objects.first() inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1') InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1) device1.refresh_from_db() diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index 101a5b5a3..c0b636e33 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -80,5 +80,3 @@ class ClusterSerializer(NetBoxModelSerializer): serializer = get_serializer_for_model(obj.scope) context = {'request': self.context['request']} return serializer(obj.scope, nested=True, context=context).data - - diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index f51e0e3f5..6052c8936 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -48,7 +48,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType): Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], ], strawberry.union("ClusterScopeType")] | None: - return self.scope + return self.scope @strawberry_django.type( diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py index 63a888ac3..b7af25f8b 100644 --- a/netbox/virtualization/migrations/0044_cluster_scope.py +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -3,17 +3,17 @@ from django.db import migrations, models def copy_site_assignments(apps, schema_editor): - """ - Copy site ForeignKey values to the scope GFK. - """ - ContentType = apps.get_model('contenttypes', 'ContentType') - Cluster = apps.get_model('virtualization', 'Cluster') - Site = apps.get_model('dcim', 'Site') + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Cluster = apps.get_model('virtualization', 'Cluster') + Site = apps.get_model('dcim', 'Site') - Cluster.objects.filter(site__isnull=False).update( - scope_type=ContentType.objects.get_for_model(Site), - scope_id=models.F('site_id') - ) + Cluster.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') + ) class Migration(migrations.Migration): diff --git a/ruff.toml b/ruff.toml index 854404469..94a0e1c61 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,2 +1,4 @@ [lint] +extend-select = ["E1", "E2", "E3", "W"] ignore = ["E501", "F403", "F405"] +preview = true From 03d413565f289f3f410ec4e76e938a1613e3cfe0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Nov 2024 14:10:15 -0500 Subject: [PATCH 3/6] Fix linter error --- netbox/wireless/forms/bulk_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 5bf2d7dcd..f23ccf203 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -7,7 +7,7 @@ from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * From 75aeaab8eebb3920ead490da242ec6d8a9e0009c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 04:55:32 -0800 Subject: [PATCH 4/6] 12596 Add Allocated Resources to Cluster API (#17956) * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 review changes * 12596 review changes --- netbox/virtualization/api/serializers_/clusters.py | 10 +++++++++- netbox/virtualization/api/views.py | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index c0b636e33..450924fef 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -59,6 +59,14 @@ class ClusterSerializer(NetBoxModelSerializer): ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) + allocated_vcpus = serializers.DecimalField( + read_only=True, + max_digits=8, + decimal_places=2, + + ) + allocated_memory = serializers.IntegerField(read_only=True) + allocated_disk = serializers.IntegerField(read_only=True) # Related object counts device_count = RelatedObjectCountField('devices') @@ -69,7 +77,7 @@ class ClusterSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'scope_id', 'scope', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'virtualmachine_count', + 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk' ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index fdf1d71be..93980ce28 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,3 +1,4 @@ +from django.db.models import Sum from rest_framework.routers import APIRootView from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin @@ -33,7 +34,11 @@ class ClusterGroupViewSet(NetBoxModelViewSet): class ClusterViewSet(NetBoxModelViewSet): - queryset = Cluster.objects.all() + queryset = Cluster.objects.prefetch_related('virtual_machines').annotate( + allocated_vcpus=Sum('virtual_machines__vcpus'), + allocated_memory=Sum('virtual_machines__memory'), + allocated_disk=Sum('virtual_machines__disk'), + ) serializer_class = serializers.ClusterSerializer filterset_class = filtersets.ClusterFilterSet From 6ab0792f02495903045fbc8bb8efb0618e88f631 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 06:32:09 -0800 Subject: [PATCH 5/6] Closes #11279: Replace `_name` natural key sorting with collation (#18009) * 11279 add collation * 11279 add collation * 11279 add collation * 11279 add collation * 11279 fix tables /tests * 11279 fix tests * 11279 refactor VirtualDisk * Clean up migrations * Misc cleanup * Correct errant file inclusion --------- Co-authored-by: Jeremy Stretch --- .../migrations/0049_natural_ordering.py | 22 ++ netbox/circuits/models/providers.py | 6 +- netbox/core/tests/test_changelog.py | 28 -- netbox/dcim/graphql/types.py | 20 +- .../migrations/0197_natural_sort_collation.py | 17 + .../dcim/migrations/0198_natural_ordering.py | 318 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 14 +- netbox/dcim/models/device_components.py | 12 +- netbox/dcim/models/devices.py | 19 +- netbox/dcim/models/power.py | 6 +- netbox/dcim/models/racks.py | 10 +- netbox/dcim/models/sites.py | 11 +- netbox/dcim/tables/devices.py | 18 +- netbox/dcim/tables/devicetypes.py | 8 +- netbox/dcim/tables/racks.py | 1 - netbox/dcim/views.py | 7 +- .../ipam/migrations/0076_natural_ordering.py | 32 ++ netbox/ipam/models/asns.py | 3 +- netbox/ipam/models/vlans.py | 3 +- netbox/ipam/models/vrfs.py | 6 +- .../migrations/0017_natural_ordering.py | 27 ++ netbox/tenancy/models/contacts.py | 3 +- netbox/tenancy/models/tenants.py | 6 +- netbox/utilities/fields.py | 3 +- netbox/virtualization/graphql/types.py | 3 +- .../migrations/0046_natural_ordering.py | 43 +++ netbox/virtualization/models/clusters.py | 3 +- .../virtualization/models/virtualmachines.py | 34 +- .../virtualization/tables/virtualmachines.py | 1 - .../vpn/migrations/0007_natural_ordering.py | 47 +++ netbox/vpn/models/crypto.py | 15 +- netbox/vpn/models/l2vpn.py | 3 +- netbox/vpn/models/tunnels.py | 3 +- .../migrations/0012_natural_ordering.py | 17 + netbox/wireless/models.py | 3 +- 35 files changed, 622 insertions(+), 150 deletions(-) create mode 100644 netbox/circuits/migrations/0049_natural_ordering.py create mode 100644 netbox/dcim/migrations/0197_natural_sort_collation.py create mode 100644 netbox/dcim/migrations/0198_natural_ordering.py create mode 100644 netbox/ipam/migrations/0076_natural_ordering.py create mode 100644 netbox/tenancy/migrations/0017_natural_ordering.py create mode 100644 netbox/virtualization/migrations/0046_natural_ordering.py create mode 100644 netbox/vpn/migrations/0007_natural_ordering.py create mode 100644 netbox/wireless/migrations/0012_natural_ordering.py diff --git a/netbox/circuits/migrations/0049_natural_ordering.py b/netbox/circuits/migrations/0049_natural_ordering.py new file mode 100644 index 000000000..1b4f565e8 --- /dev/null +++ b/netbox/circuits/migrations/0049_natural_ordering.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0048_circuitterminations_cached_relations'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterField( + model_name='provider', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='providernetwork', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index f0fe77b1a..be81caa54 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -21,7 +21,8 @@ class Provider(ContactsMixin, PrimaryModel): verbose_name=_('name'), max_length=100, unique=True, - help_text=_('Full name of the provider') + help_text=_('Full name of the provider'), + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -95,7 +96,8 @@ class ProviderNetwork(PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) provider = models.ForeignKey( to='circuits.Provider', diff --git a/netbox/core/tests/test_changelog.py b/netbox/core/tests/test_changelog.py index c58968ee8..4914dbaf3 100644 --- a/netbox/core/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -76,10 +76,6 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.postchange_data) - self.assertNotIn('_name', oc.postchange_data_clean) - def test_update_object(self): site = Site(name='Site 1', slug='site-1') site.save() @@ -117,12 +113,6 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.prechange_data) - self.assertNotIn('_name', oc.prechange_data_clean) - self.assertIn('_name', oc.postchange_data) - self.assertNotIn('_name', oc.postchange_data_clean) - def test_delete_object(self): site = Site( name='Site 1', @@ -153,10 +143,6 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.prechange_data) - self.assertNotIn('_name', oc.prechange_data_clean) - def test_bulk_update_objects(self): sites = ( Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE), @@ -353,10 +339,6 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.postchange_data) - self.assertNotIn('_name', oc.postchange_data_clean) - def test_update_object(self): site = Site(name='Site 1', slug='site-1') site.save() @@ -389,12 +371,6 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.prechange_data) - self.assertNotIn('_name', oc.prechange_data_clean) - self.assertIn('_name', oc.postchange_data) - self.assertNotIn('_name', oc.postchange_data_clean) - def test_delete_object(self): site = Site( name='Site 1', @@ -423,10 +399,6 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) - # Check that private attributes were included in raw data but not display data - self.assertIn('_name', oc.prechange_data) - self.assertNotIn('_name', oc.prechange_data_clean) - def test_bulk_create_objects(self): data = ( { diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 6493ec6b1..fc5f35780 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -76,7 +76,6 @@ class ComponentType( """ Base type for device/VM components """ - _name: str device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] @@ -93,7 +92,6 @@ class ComponentTemplateType( """ Base type for device/VM components """ - _name: str device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] @@ -181,7 +179,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin filters=ConsolePortTemplateFilter ) class ConsolePortTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -199,7 +197,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin filters=ConsoleServerPortTemplateFilter ) class ConsoleServerPortTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -208,7 +206,6 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): filters=DeviceFilter ) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str console_port_count: BigInt console_server_port_count: BigInt power_port_count: BigInt @@ -273,7 +270,7 @@ class DeviceBayType(ComponentType): filters=DeviceBayTemplateFilter ) class DeviceBayTemplateType(ComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -282,7 +279,6 @@ class DeviceBayTemplateType(ComponentTemplateType): filters=InventoryItemTemplateFilter ) class InventoryItemTemplateType(ComponentTemplateType): - _name: str role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -366,7 +362,6 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): filters=FrontPortTemplateFilter ) class FrontPortTemplateType(ModularComponentTemplateType): - _name: str color: str rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] @@ -377,6 +372,7 @@ class FrontPortTemplateType(ModularComponentTemplateType): filters=InterfaceFilter ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): + _name: str mac_address: str | None wwn: str | None parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None @@ -527,7 +523,7 @@ class ModuleBayType(ModularComponentType): filters=ModuleBayTemplateFilter ) class ModuleBayTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -588,7 +584,6 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin filters=PowerOutletTemplateFilter ) class PowerOutletTemplateType(ModularComponentTemplateType): - _name: str power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None @@ -620,8 +615,6 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): filters=PowerPortTemplateFilter ) class PowerPortTemplateType(ModularComponentTemplateType): - _name: str - poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -640,7 +633,6 @@ class RackTypeType(NetBoxObjectType): filters=RackFilter ) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -693,7 +685,6 @@ class RearPortType(ModularComponentType, CabledObjectMixin): filters=RearPortTemplateFilter ) class RearPortTemplateType(ModularComponentTemplateType): - _name: str color: str frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -729,7 +720,6 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): filters=SiteFilter ) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str time_zone: str | None region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None diff --git a/netbox/dcim/migrations/0197_natural_sort_collation.py b/netbox/dcim/migrations/0197_natural_sort_collation.py new file mode 100644 index 000000000..a77632b37 --- /dev/null +++ b/netbox/dcim/migrations/0197_natural_sort_collation.py @@ -0,0 +1,17 @@ +from django.contrib.postgres.operations import CreateCollation +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0196_qinq_svlan'), + ] + + operations = [ + CreateCollation( + "natural_sort", + provider="icu", + locale="und-u-kn-true", + ), + ] diff --git a/netbox/dcim/migrations/0198_natural_ordering.py b/netbox/dcim/migrations/0198_natural_ordering.py new file mode 100644 index 000000000..83e94a195 --- /dev/null +++ b/netbox/dcim/migrations/0198_natural_ordering.py @@ -0,0 +1,318 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterModelOptions( + name='site', + options={'ordering': ('name',)}, + ), + migrations.AlterField( + model_name='site', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterModelOptions( + name='consoleport', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='consoleserverport', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='device', + options={'ordering': ('name', 'pk')}, + ), + migrations.AlterModelOptions( + name='devicebay', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='devicebaytemplate', + options={'ordering': ('device_type', 'name')}, + ), + migrations.AlterModelOptions( + name='frontport', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='inventoryitem', + options={'ordering': ('device__id', 'parent__id', 'name')}, + ), + migrations.AlterModelOptions( + name='inventoryitemtemplate', + options={'ordering': ('device_type__id', 'parent__id', 'name')}, + ), + migrations.AlterModelOptions( + name='modulebay', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='modulebaytemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='poweroutlet', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='powerport', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'location', 'name', 'pk')}, + ), + migrations.AlterModelOptions( + name='rearport', + options={'ordering': ('device', 'name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', 'module_type', 'name')}, + ), + migrations.RemoveField( + model_name='consoleport', + name='_name', + ), + migrations.RemoveField( + model_name='consoleporttemplate', + name='_name', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='_name', + ), + migrations.RemoveField( + model_name='consoleserverporttemplate', + name='_name', + ), + migrations.RemoveField( + model_name='device', + name='_name', + ), + migrations.RemoveField( + model_name='devicebay', + name='_name', + ), + migrations.RemoveField( + model_name='devicebaytemplate', + name='_name', + ), + migrations.RemoveField( + model_name='frontport', + name='_name', + ), + migrations.RemoveField( + model_name='frontporttemplate', + name='_name', + ), + migrations.RemoveField( + model_name='inventoryitem', + name='_name', + ), + migrations.RemoveField( + model_name='inventoryitemtemplate', + name='_name', + ), + migrations.RemoveField( + model_name='modulebay', + name='_name', + ), + migrations.RemoveField( + model_name='modulebaytemplate', + name='_name', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='_name', + ), + migrations.RemoveField( + model_name='poweroutlettemplate', + name='_name', + ), + migrations.RemoveField( + model_name='powerport', + name='_name', + ), + migrations.RemoveField( + model_name='powerporttemplate', + name='_name', + ), + migrations.RemoveField( + model_name='rack', + name='_name', + ), + migrations.RemoveField( + model_name='rearport', + name='_name', + ), + migrations.RemoveField( + model_name='rearporttemplate', + name='_name', + ), + migrations.RemoveField( + model_name='site', + name='_name', + ), + migrations.AlterField( + model_name='consoleport', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='consoleserverport', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='device', + name='name', + field=models.CharField(blank=True, db_collation='natural_sort', max_length=64, null=True), + ), + migrations.AlterField( + model_name='devicebay', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='frontport', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='interface', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='inventoryitem', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='modulebay', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='poweroutlet', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='powerport', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='rack', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='rearport', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='powerfeed', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='powerpanel', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='virtualchassis', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='virtualdevicecontext', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 00555d49e..ddd4d2426 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -44,12 +44,8 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): max_length=64, help_text=_( "{module} is accepted as a substitution for the module bay position when attached to a module type." - ) - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + ), + db_collation="natural_sort" ) label = models.CharField( verbose_name=_('label'), @@ -65,7 +61,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): class Meta: abstract = True - ordering = ('device_type', '_name') + ordering = ('device_type', 'name') constraints = ( models.UniqueConstraint( fields=('device_type', 'name'), @@ -125,7 +121,7 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True - ordering = ('device_type', 'module_type', '_name') + ordering = ('device_type', 'module_type', 'name') constraints = ( models.UniqueConstraint( fields=('device_type', 'name'), @@ -782,7 +778,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): component_model = InventoryItem class Meta: - ordering = ('device_type__id', 'parent__id', '_name') + ordering = ('device_type__id', 'parent__id', 'name') indexes = ( models.Index(fields=('component_type', 'component_id')), ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 36fd02add..31278a13c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -50,12 +50,8 @@ class ComponentModel(NetBoxModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) label = models.CharField( verbose_name=_('label'), @@ -71,7 +67,7 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True - ordering = ('device', '_name') + ordering = ('device', 'name') constraints = ( models.UniqueConstraint( fields=('device', 'name'), @@ -1301,7 +1297,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id') class Meta: - ordering = ('device__id', 'parent__id', '_name') + ordering = ('device__id', 'parent__id', 'name') indexes = ( models.Index(fields=('component_type', 'component_id')), ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 47f4ee6c9..a836c5d37 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -23,7 +23,7 @@ from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField +from utilities.fields import ColorField, CounterCacheField from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin @@ -582,13 +582,8 @@ class Device( verbose_name=_('name'), max_length=64, blank=True, - null=True - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True, - null=True + null=True, + db_collation="natural_sort" ) serial = models.CharField( max_length=50, @@ -775,7 +770,7 @@ class Device( ) class Meta: - ordering = ('_name', 'pk') # Name may be null + ordering = ('name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( Lower('name'), 'site', 'tenant', @@ -1320,7 +1315,8 @@ class VirtualChassis(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 + max_length=64, + db_collation="natural_sort" ) domain = models.CharField( verbose_name=_('domain'), @@ -1382,7 +1378,8 @@ class VirtualDeviceContext(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 + max_length=64, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index d0c6b18b6..284cfe832 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -36,7 +36,8 @@ class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) prerequisite_models = ( @@ -86,7 +87,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 013dfb619..08b7f5a35 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -19,7 +19,7 @@ from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.conversion import to_grams from utilities.data import array_to_string, drange -from utilities.fields import ColorField, NaturalOrderingField +from utilities.fields import ColorField from .device_components import PowerPort from .devices import Device, Module from .power import PowerFeed @@ -255,12 +255,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): ) name = models.CharField( verbose_name=_('name'), - max_length=100 - ) - _name = NaturalOrderingField( - target_field='name', max_length=100, - blank=True + db_collation="natural_sort" ) facility_id = models.CharField( max_length=50, @@ -340,7 +336,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): ) class Meta: - ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique + ordering = ('site', 'location', 'name', 'pk') # (site, location, name) may be non-unique constraints = ( # Name and facility_id must be unique *only* within a Location models.UniqueConstraint( diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a290f4119..0985a8d7a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -8,7 +8,6 @@ from dcim.choices import * from dcim.constants import * from netbox.models import NestedGroupModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.fields import NaturalOrderingField __all__ = ( 'Location', @@ -143,12 +142,8 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): verbose_name=_('name'), max_length=100, unique=True, - help_text=_("Full name of the site") - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + help_text=_("Full name of the site"), + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -245,7 +240,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): ) class Meta: - ordering = ('_name',) + ordering = ('name',) verbose_name = _('site') verbose_name_plural = _('sites') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index fed33401c..b7634626d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -132,7 +132,6 @@ class PlatformTable(NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( verbose_name=_('Name'), - order_by=('_name',), template_code=DEVICE_LINK, linkify=True ) @@ -288,7 +287,6 @@ class DeviceComponentTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), linkify=True, - order_by=('_name',) ) device_status = columns.ChoiceFieldColumn( accessor=tables.A('device__status'), @@ -391,7 +389,6 @@ class DeviceConsolePortTable(ConsolePortTable): name = tables.TemplateColumn( verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -433,7 +430,6 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -482,7 +478,6 @@ class DevicePowerPortTable(PowerPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -531,7 +526,6 @@ class DevicePowerOutletTable(PowerOutletTable): name = tables.TemplateColumn( verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -550,6 +544,11 @@ class DevicePowerOutletTable(PowerOutletTable): class BaseInterfaceTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True, + order_by=('_name',) + ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) @@ -597,7 +596,7 @@ class BaseInterfaceTable(NetBoxTable): return ",".join([str(obj) for obj in value.all()]) -class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( verbose_name=_('Device'), linkify={ @@ -736,7 +735,6 @@ class DeviceFrontPortTable(FrontPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -783,7 +781,6 @@ class DeviceRearPortTable(RearPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -846,7 +843,6 @@ class DeviceDeviceBayTable(DeviceBayTable): verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -915,7 +911,6 @@ class DeviceModuleBayTable(ModuleBayTable): name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True, - order_by=Accessor('_name') ) actions = columns.ActionsColumn( extra_buttons=MODULEBAY_BUTTONS @@ -982,7 +977,6 @@ class DeviceInventoryItemTable(InventoryItemTable): verbose_name=_('Name'), template_code='' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index e8a4e35f1..a7f8f08e8 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -163,9 +163,7 @@ class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name=_('ID') ) - name = tables.Column( - order_by=('_name',) - ) + name = tables.Column() class Meta(NetBoxTable.Meta): exclude = ('id', ) @@ -220,6 +218,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): + name = tables.Column( + verbose_name=_('Name'), + order_by=('_name',) + ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index a6b704161..dbd99ca24 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -111,7 +111,6 @@ class RackTypeTable(NetBoxTable): class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( verbose_name=_('Name'), - order_by=('_name',), linkify=True ) location = tables.Column( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9a821a384..7a5a771a9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -688,8 +688,7 @@ class RackElevationListView(generic.ObjectListView): sort = request.GET.get('sort', 'name') if sort not in ORDERING_CHOICES: sort = 'name' - sort_field = sort.replace("name", "_name") # Use natural ordering - racks = racks.order_by(sort_field) + racks = racks.order_by(sort) # Pagination per_page = get_paginate_count(request) @@ -731,8 +730,8 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): peer_racks = peer_racks.filter(location=instance.location) else: peer_racks = peer_racks.filter(location__isnull=True) - next_rack = peer_racks.filter(_name__gt=instance._name).first() - prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first() + next_rack = peer_racks.filter(name__gt=instance.name).first() + prev_rack = peer_racks.filter(name__lt=instance.name).reverse().first() # Determine any additional parameters to pass when embedding the rack elevations svg_extra = '&'.join([ diff --git a/netbox/ipam/migrations/0076_natural_ordering.py b/netbox/ipam/migrations/0076_natural_ordering.py new file mode 100644 index 000000000..8c7bfaea1 --- /dev/null +++ b/netbox/ipam/migrations/0076_natural_ordering.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0075_vlan_qinq'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterField( + model_name='asnrange', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='routetarget', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=21, unique=True), + ), + migrations.AlterField( + model_name='vlangroup', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='vrf', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + ] diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index eb47426b2..c1d251301 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -16,7 +16,8 @@ class ASNRange(OrganizationalModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7832cfc67..fa31fd608 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -35,7 +35,8 @@ class VLANGroup(OrganizationalModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 26afb7927..6a8b8d649 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -18,7 +18,8 @@ class VRF(PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) rd = models.CharField( max_length=VRF_RD_MAX_LENGTH, @@ -74,7 +75,8 @@ class RouteTarget(PrimaryModel): verbose_name=_('name'), max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) unique=True, - help_text=_('Route target value (formatted in accordance with RFC 4360)') + help_text=_('Route target value (formatted in accordance with RFC 4360)'), + db_collation="natural_sort" ) tenant = models.ForeignKey( to='tenancy.Tenant', diff --git a/netbox/tenancy/migrations/0017_natural_ordering.py b/netbox/tenancy/migrations/0017_natural_ordering.py new file mode 100644 index 000000000..de1fb49aa --- /dev/null +++ b/netbox/tenancy/migrations/0017_natural_ordering.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0016_charfield_null_choices'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterField( + model_name='contact', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.AlterField( + model_name='tenantgroup', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 24ffef0cf..3969c8317 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -56,7 +56,8 @@ class Contact(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) title = models.CharField( verbose_name=_('title'), diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 7a2d9c2f8..55f0c5933 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -18,7 +18,8 @@ class TenantGroup(NestedGroupModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -39,7 +40,8 @@ class Tenant(ContactsMixin, PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index ee71223cb..1d16a1d3f 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -5,7 +5,6 @@ from django.db import models from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from utilities.ordering import naturalize from .forms.widgets import ColorSelect from .validators import ColorValidator @@ -40,7 +39,7 @@ class NaturalOrderingField(models.CharField): """ description = "Stores a representation of its target field suitable for natural ordering" - def __init__(self, target_field, naturalize_function=naturalize, *args, **kwargs): + def __init__(self, target_field, naturalize_function, *args, **kwargs): self.target_field = target_field self.naturalize_function = naturalize_function super().__init__(*args, **kwargs) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 6052c8936..8476eac7e 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -25,7 +25,6 @@ class ComponentType(NetBoxObjectType): """ Base type for device/VM components """ - _name: str virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] @@ -77,7 +76,6 @@ class ClusterTypeType(OrganizationalObjectType): filters=VirtualMachineFilter ) class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): - _name: str interface_count: BigInt virtual_disk_count: BigInt interface_count: BigInt @@ -102,6 +100,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): filters=VMInterfaceFilter ) class VMInterfaceType(IPAddressesMixin, ComponentType): + _name: str mac_address: str | None parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None diff --git a/netbox/virtualization/migrations/0046_natural_ordering.py b/netbox/virtualization/migrations/0046_natural_ordering.py new file mode 100644 index 000000000..9284b6331 --- /dev/null +++ b/netbox/virtualization/migrations/0046_natural_ordering.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0045_clusters_cached_relations'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterModelOptions( + name='virtualmachine', + options={'ordering': ('name', 'pk')}, + ), + migrations.AlterModelOptions( + name='virtualdisk', + options={'ordering': ('virtual_machine', 'name')}, + ), + migrations.RemoveField( + model_name='virtualmachine', + name='_name', + ), + migrations.AlterField( + model_name='virtualdisk', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='virtualmachine', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=64), + ), + migrations.AlterField( + model_name='cluster', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100), + ), + migrations.RemoveField( + model_name='virtualdisk', + name='_name', + ), + ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 601ee7f23..9f7b97e29 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -50,7 +50,8 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) type = models.ForeignKey( verbose_name=_('type'), diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 4ee41e403..ebfb2d6c5 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -69,12 +69,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) status = models.CharField( max_length=50, @@ -152,7 +148,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co ) class Meta: - ordering = ('_name', 'pk') # Name may be non-unique + ordering = ('name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( Lower('name'), 'cluster', 'tenant', @@ -273,13 +269,8 @@ class ComponentModel(NetBoxModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - naturalize_function=naturalize_interface, - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) description = models.CharField( verbose_name=_('description'), @@ -289,7 +280,6 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True - ordering = ('virtual_machine', CollateAsChar('_name')) constraints = ( models.UniqueConstraint( fields=('virtual_machine', 'name'), @@ -311,10 +301,9 @@ class ComponentModel(NetBoxModel): class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='interfaces' # Override ComponentModel + name = models.CharField( + verbose_name=_('name'), + max_length=64, ) _name = NaturalOrderingField( target_field='name', @@ -322,6 +311,11 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): max_length=100, blank=True ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' # Override ComponentModel + ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', @@ -358,6 +352,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): class Meta(ComponentModel.Meta): verbose_name = _('interface') verbose_name_plural = _('interfaces') + ordering = ('virtual_machine', CollateAsChar('_name')) def clean(self): super().clean() @@ -416,3 +411,4 @@ class VirtualDisk(ComponentModel, TrackingModelMixin): class Meta(ComponentModel.Meta): verbose_name = _('virtual disk') verbose_name_plural = _('virtual disks') + ordering = ('virtual_machine', 'name') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 4a3138711..26d32f8cf 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -53,7 +53,6 @@ VMINTERFACE_BUTTONS = """ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( verbose_name=_('Name'), - order_by=('_name',), linkify=True ) status = columns.ChoiceFieldColumn( diff --git a/netbox/vpn/migrations/0007_natural_ordering.py b/netbox/vpn/migrations/0007_natural_ordering.py new file mode 100644 index 000000000..01dd4620f --- /dev/null +++ b/netbox/vpn/migrations/0007_natural_ordering.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0006_charfield_null_choices'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterField( + model_name='ikepolicy', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='ikeproposal', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='ipsecpolicy', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='ipsecprofile', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='ipsecproposal', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='l2vpn', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tunnel', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 2b721ec29..8e991b578 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -22,7 +22,8 @@ class IKEProposal(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) authentication_method = models.CharField( verbose_name=('authentication method'), @@ -67,7 +68,8 @@ class IKEPolicy(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) version = models.PositiveSmallIntegerField( verbose_name=_('version'), @@ -125,7 +127,8 @@ class IPSecProposal(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) encryption_algorithm = models.CharField( verbose_name=_('encryption'), @@ -176,7 +179,8 @@ class IPSecPolicy(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) proposals = models.ManyToManyField( to='vpn.IPSecProposal', @@ -211,7 +215,8 @@ class IPSecProfile(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) mode = models.CharField( verbose_name=_('mode'), diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index b799ab32d..3e562531d 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -20,7 +20,8 @@ class L2VPN(ContactsMixin, PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 3a0f1dc14..714024a81 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -31,7 +31,8 @@ class Tunnel(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/wireless/migrations/0012_natural_ordering.py b/netbox/wireless/migrations/0012_natural_ordering.py new file mode 100644 index 000000000..da818bdd9 --- /dev/null +++ b/netbox/wireless/migrations/0012_natural_ordering.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ('dcim', '0197_natural_sort_collation'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslangroup', + name='name', + field=models.CharField(db_collation='natural_sort', max_length=100, unique=True), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index d78c893a6..61ff72bc1 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -52,7 +52,8 @@ class WirelessLANGroup(NestedGroupModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), From 9fe6685562d0b6918c7acb6a83678bc399a3fd8a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 11:55:46 -0800 Subject: [PATCH 6/6] 17929 Add Scope Mixins to Prefix (#17930) * 17929 Add Scope Mixins to Prefix * 17929 Add Scope Mixins to Prefix * 17929 fixes for tests * 17929 merge latest scope changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 17929 fix migrations --- netbox/dcim/api/serializers_/sites.py | 8 +-- netbox/dcim/base_filtersets.py | 67 ++++++++++++++++++ netbox/dcim/filtersets.py | 58 ---------------- netbox/dcim/graphql/types.py | 8 +-- netbox/dcim/models/mixins.py | 4 -- netbox/ipam/api/serializers_/ip.py | 5 +- netbox/ipam/apps.py | 2 +- netbox/ipam/constants.py | 5 -- netbox/ipam/filtersets.py | 56 +-------------- netbox/ipam/forms/bulk_edit.py | 30 +------- netbox/ipam/forms/bulk_import.py | 10 +-- netbox/ipam/forms/model_forms.py | 46 +------------ netbox/ipam/graphql/types.py | 2 +- .../0072_prefix_cached_relations.py | 14 ++-- netbox/ipam/models/ip.py | 69 +------------------ netbox/utilities/api.py | 2 +- netbox/virtualization/filtersets.py | 3 +- ...location_alter_cluster__region_and_more.py | 41 +++++++++++ ...l_ordering.py => 0047_natural_ordering.py} | 2 +- netbox/wireless/filtersets.py | 2 +- ...12_alter_wirelesslan__location_and_more.py | 41 +++++++++++ ...l_ordering.py => 0013_natural_ordering.py} | 2 +- 22 files changed, 187 insertions(+), 290 deletions(-) create mode 100644 netbox/dcim/base_filtersets.py create mode 100644 netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py rename netbox/virtualization/migrations/{0046_natural_ordering.py => 0047_natural_ordering.py} (93%) create mode 100644 netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py rename netbox/wireless/migrations/{0012_natural_ordering.py => 0013_natural_ordering.py} (82%) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 7cd89e38c..b818cd954 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -21,7 +21,7 @@ __all__ = ( class RegionSerializer(NestedGroupModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = Region @@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer): parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = SiteGroup @@ -63,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer): # Related object counts circuit_count = RelatedObjectCountField('circuit_terminations') device_count = RelatedObjectCountField('devices') - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') rack_count = RelatedObjectCountField('racks') vlan_count = RelatedObjectCountField('vlans') virtualmachine_count = RelatedObjectCountField('virtual_machines') @@ -86,7 +86,7 @@ class LocationSerializer(NestedGroupModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True, default=0) device_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = Location diff --git a/netbox/dcim/base_filtersets.py b/netbox/dcim/base_filtersets.py new file mode 100644 index 000000000..c007c0120 --- /dev/null +++ b/netbox/dcim/base_filtersets.py @@ -0,0 +1,67 @@ +import django_filters + +from django.utils.translation import gettext as _ +from netbox.filtersets import BaseFilterSet +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter +from .models import * + +__all__ = ( + 'ScopedFilterSet', +) + + +class ScopedFilterSet(BaseFilterSet): + """ + Provides additional filtering functionality for location, site, etc.. for Scoped models. + """ + scope_type = ContentTypeFilter() + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='_region', + lookup_expr='in', + label=_('Region (ID)'), + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='_region', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='_site_group', + lookup_expr='in', + label=_('Site group (ID)'), + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='_site_group', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + field_name='_site', + label=_('Site (ID)'), + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='_site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label=_('Site (slug)'), + ) + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + label=_('Location (ID)'), + ) + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + to_field_name='slug', + label=_('Location (slug)'), + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index df66ad77b..0371f882b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -73,7 +73,6 @@ __all__ = ( 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', - 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2345,60 +2344,3 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface fields = tuple() - - -class ScopedFilterSet(BaseFilterSet): - """ - Provides additional filtering functionality for location, site, etc.. for Scoped models. - """ - scope_type = ContentTypeFilter() - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='_region', - lookup_expr='in', - label=_('Region (ID)'), - ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='_region', - lookup_expr='in', - to_field_name='slug', - label=_('Region (slug)'), - ) - site_group_id = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='_site_group', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='_site_group', - lookup_expr='in', - to_field_name='slug', - label=_('Site group (slug)'), - ) - site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), - field_name='_site', - label=_('Site (ID)'), - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='_site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label=_('Site (slug)'), - ) - location_id = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - label=_('Location (ID)'), - ) - location = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - to_field_name='slug', - label=_('Location (slug)'), - ) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index fc5f35780..cc1bcac0f 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -461,7 +461,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -707,7 +707,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -739,7 +739,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -763,7 +763,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 1df3364c4..ac4d7dab9 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -59,28 +59,24 @@ class CachedScopeMixin(models.Model): _location = models.ForeignKey( to='dcim.Location', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _region = models.ForeignKey( to='dcim.Region', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _site_group = models.ForeignKey( to='dcim.SiteGroup', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 0c3c141af..bfc7ac546 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from dcim.constants import LOCATION_SCOPE_TYPES from ipam.choices import * -from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer @@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer): vrf = VRFSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( queryset=ContentType.objects.filter( - model__in=PREFIX_SCOPE_TYPES + model__in=LOCATION_SCOPE_TYPES ), allow_null=True, required=False, diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index e0463dfce..ae88d69a9 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -18,7 +18,7 @@ class IPAMConfig(AppConfig): # Register denormalized fields denormalized.register(Prefix, '_site', { '_region': 'region', - '_sitegroup': 'group', + '_site_group': 'group', }) denormalized.register(Prefix, '_location', { '_site': 'site', diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index c07b8441f..6dffd3287 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -23,11 +23,6 @@ VRF_RD_MAX_LENGTH = 21 PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MAX = 127 # IPv6 -# models values for ContentTypes which may be Prefix scope types -PREFIX_SCOPE_TYPES = ( - 'region', 'sitegroup', 'site', 'location', -) - # # IPAddresses diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88c869a50..c762c15fe 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from dcim.base_filtersets import ScopedFilterSet from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q @@ -9,7 +10,7 @@ from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError from circuits.models import Provider -from dcim.models import Device, Interface, Location, Region, Site, SiteGroup +from dcim.models import Device, Interface, Region, Site, SiteGroup from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description', 'weight') -class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): family = django_filters.NumberFilter( field_name='prefix', lookup_expr='family' @@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('VRF (RD)'), ) - scope_type = ContentTypeFilter() - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='_region', - lookup_expr='in', - label=_('Region (ID)'), - ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='_region', - lookup_expr='in', - to_field_name='slug', - label=_('Region (slug)'), - ) - site_group_id = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='_sitegroup', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='_sitegroup', - lookup_expr='in', - to_field_name='slug', - label=_('Site group (slug)'), - ) - site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), - field_name='_site', - label=_('Site (ID)'), - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='_site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label=_('Site (slug)'), - ) - location_id = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - label=_('Location (ID)'), - ) - location = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - to_field_name='slug', - label=_('Location (slug)'), - ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), label=_('VLAN (ID)'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index c323a41c1..7f3216cfd 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ +from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * @@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class PrefixBulkEditForm(NetBoxModelBulkEditForm): - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), - required=False, - label=_('Scope type') - ) - scope = DynamicModelChoiceField( - label=_('Scope'), - queryset=Site.objects.none(), # Initial queryset - required=False, - disabled=True, - selector=True - ) +class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): 'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if scope_type_id := get_field_value(self, 'scope_type'): - try: - scope_type = ContentType.objects.get(pk=scope_type_id) - model = scope_type.model_class() - self.fields['scope'].queryset = model.objects.all() - self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower - self.fields['scope'].disabled = False - self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) - except ObjectDoesNotExist: - pass - class IPRangeBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3be4ccc59..7e1382be9 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedImportForm from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'weight', 'description', 'tags') -class PrefixImportForm(NetBoxModelImportForm): +class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) - scope_type = CSVContentTypeField( - queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), - required=False, - label=_('Scope type (app & model)') - ) vlan_group = CSVModelChoiceField( label=_('VLAN group'), queryset=VLANGroup.objects.all(), @@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm): 'mark_utilized', 'description', 'comments', 'tags', ) labels = { - 'scope_id': 'Scope ID', + 'scope_id': _('Scope ID'), } def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3d0cd3dd1..56a6dc3d9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedForm from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField @@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm): ] -class PrefixForm(TenancyForm, NetBoxModelForm): +class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label=_('VRF') ) - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), - widget=HTMXSelect(), - required=False, - label=_('Scope type') - ) - scope = DynamicModelChoiceField( - label=_('Scope'), - queryset=Site.objects.none(), # Initial queryset - required=False, - disabled=True, - selector=True - ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm): 'tenant', 'description', 'comments', 'tags', ] - def __init__(self, *args, **kwargs): - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}) - - if instance is not None and instance.scope: - initial['scope'] = instance.scope - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - - if scope_type_id := get_field_value(self, 'scope_type'): - try: - scope_type = ContentType.objects.get(pk=scope_type_id) - model = scope_type.model_class() - self.fields['scope'].queryset = model.objects.all() - self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower - self.fields['scope'].disabled = False - self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) - except ObjectDoesNotExist: - pass - - if self.instance and scope_type_id != self.instance.scope_type_id: - self.initial['scope'] = None - - def clean(self): - super().clean() - - # Assign the selected scope (if any) - self.instance.scope = self.cleaned_data.get('scope') - class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 2ef63cf0c..5a4813e0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -154,7 +154,7 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=PrefixFilter ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 2b457ebda..4b438f7d5 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor): prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') for prefix in prefixes: prefix._region_id = prefix.site.region_id - prefix._sitegroup_id = prefix.site.group_id + prefix._site_group_id = prefix.site.group_id prefix._site_id = prefix.site_id # Note: Location cannot be set prior to migration - Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site']) + Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site']) class Migration(migrations.Migration): @@ -29,22 +29,22 @@ class Migration(migrations.Migration): migrations.AddField( model_name='prefix', name='_location', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'), ), migrations.AddField( model_name='prefix', name='_region', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'), ), migrations.AddField( model_name='prefix', name='_site', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'), ), migrations.AddField( model_name='prefix', - name='_sitegroup', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), + name='_site_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'), ), # Populate denormalized FK values diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b17e26169..dcecbcdea 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,5 +1,4 @@ import netaddr -from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models @@ -9,6 +8,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from core.models import ObjectType +from dcim.models.mixins import CachedScopeMixin from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -198,7 +198,7 @@ class Role(OrganizationalModel): return self.name -class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): +class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. @@ -208,22 +208,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') ) - scope_type = models.ForeignKey( - to='contenttypes.ContentType', - on_delete=models.PROTECT, - limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES), - related_name='+', - blank=True, - null=True - ) - scope_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - scope = GenericForeignKey( - ct_field='scope_type', - fk_field='scope_id' - ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.PROTECT, @@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): help_text=_("Treat as fully utilized") ) - # Cached associations to enable efficient filtering - _location = models.ForeignKey( - to='dcim.Location', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _region = models.ForeignKey( - to='dcim.Region', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _sitegroup = models.ForeignKey( - to='dcim.SiteGroup', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - # Cached depth & child counts _depth = models.PositiveSmallIntegerField( default=0, @@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): super().save(*args, **kwargs) - def cache_related_objects(self): - self._region = self._sitegroup = self._site = self._location = None - if self.scope_type: - scope_type = self.scope_type.model_class() - if scope_type == apps.get_model('dcim', 'region'): - self._region = self.scope - elif scope_type == apps.get_model('dcim', 'sitegroup'): - self._sitegroup = self.scope - elif scope_type == apps.get_model('dcim', 'site'): - self._region = self.scope.region - self._sitegroup = self.scope.group - self._site = self.scope - elif scope_type == apps.get_model('dcim', 'location'): - self._region = self.scope.site.region - self._sitegroup = self.scope.site.group - self._site = self.scope.site - self._location = self.scope - cache_related_objects.alters_data = True - @property def family(self): return self.prefix.version if self.prefix else None diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 11b914811..6793c0526 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -129,7 +129,7 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None): for field_name, field in serializer_class._declared_fields.items(): if field_name in fields_to_include and type(field) is RelatedObjectCountField: - related_field = model._meta.get_field(field.relation).field + related_field = getattr(model, field.relation).field annotations[field_name] = count_related(related_field.model, related_field.name) return annotations diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ac72bea12..ab25492b5 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,8 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet +from dcim.filtersets import CommonInterfaceFilterSet +from dcim.base_filtersets import ScopedFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate diff --git a/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py new file mode 100644 index 000000000..7b1168da0 --- /dev/null +++ b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.9 on 2024-11-14 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0196_qinq_svlan'), + ('virtualization', '0045_clusters_cached_relations'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='_location', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location' + ), + ), + migrations.AlterField( + model_name='cluster', + name='_region', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region' + ), + ), + migrations.AlterField( + model_name='cluster', + name='_site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'), + ), + migrations.AlterField( + model_name='cluster', + name='_site_group', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup' + ), + ), + ] diff --git a/netbox/virtualization/migrations/0046_natural_ordering.py b/netbox/virtualization/migrations/0047_natural_ordering.py similarity index 93% rename from netbox/virtualization/migrations/0046_natural_ordering.py rename to netbox/virtualization/migrations/0047_natural_ordering.py index 9284b6331..4454cfe2d 100644 --- a/netbox/virtualization/migrations/0046_natural_ordering.py +++ b/netbox/virtualization/migrations/0047_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0045_clusters_cached_relations'), + ('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'), ('dcim', '0197_natural_sort_collation'), ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 5a4195e6c..cc5aefbd8 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from dcim.filtersets import ScopedFilterSet +from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet diff --git a/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py new file mode 100644 index 000000000..7edaff92b --- /dev/null +++ b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.9 on 2024-11-14 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0196_qinq_svlan'), + ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='_location', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location' + ), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_region', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region' + ), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_site_group', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup' + ), + ), + ] diff --git a/netbox/wireless/migrations/0012_natural_ordering.py b/netbox/wireless/migrations/0013_natural_ordering.py similarity index 82% rename from netbox/wireless/migrations/0012_natural_ordering.py rename to netbox/wireless/migrations/0013_natural_ordering.py index da818bdd9..e33c87c60 100644 --- a/netbox/wireless/migrations/0012_natural_ordering.py +++ b/netbox/wireless/migrations/0013_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ('wireless', '0012_alter_wirelesslan__location_and_more'), ('dcim', '0197_natural_sort_collation'), ]