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/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/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/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/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/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 6493ec6b1..cc1bcac0f 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 @@ -465,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')]]: @@ -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')]] @@ -716,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')]]: @@ -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 @@ -749,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')]]: @@ -773,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/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/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/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/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/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/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/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/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/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/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/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('