Repository: netbox-community/netbox
Pull request: Closes #11279: Replace `_name` natural key sorting with collation
### Fixes: #11279 

* Interface uses naturalize_interface so needed to keep _name on it, same for VMInterface
* utilities.ordering.naturalize does not look like it is used anymore but is used in migrations so can't be removed currently - we could backport changes to old migrations and remove it if wanted.

See the screenshot for sorting - accented / umlaut characters are handled better then before and look to be in the correct ordering.
![Sites | NetBox 2024-11-13 13-00-08](https://github.com/user-attachments/assets/25643e46-d598-4141-a3fc-5fd29bb47cb2)


Problem statement:
Extend the use of natural ordering to additional models
### NetBox version

v3.4.1

### Feature type

Data model extension

### Proposed functionality

Extend Virtualisation Cluster model to use natualsorting by the cluster name.

### Use case

Better sorting of the clusters without padding the numbers in the name.

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
IMO it doesn't make sense to do this only for clusters as a discrete goal. For instance, #10644 proposes also implementing natural ordering for circuits.

We use [natural ordering](https://stackoverflow.com/questions/5167928/what-is-natural-ordering-when-we-talk-about-sorting) for a few models in NetBox currently; namely sites, racks, devices, device components, VMs, and VM interfaces. This is accomplished by including a "hidden" `_name` field which stores a naturalized representation of the object's `name` suitable for database ordering.

While I'm not opposed to enabling natural ordering on other models, if we decide to do so, we should:

1. Re-evaluate the current implementation to determine if a better approach exists
2. Identify _all_ suitable models and implement the change on all
3. Ensure that the implementation is scalable (e.g. via a mixin class vs. explicit field definitions as we do currently)
> * Re-evaluate the current implementation to determine if a better approach exists

Maybe we can use postgres collation as there is support for natural ordering.

We would have to create the collation in a migration with raw sql I assume, like:

```SQL
CREATE COLLATION natural_sort (provider = icu, locale = 'en@colNumeric=yes');
```

And then the collation can be specified on the field like:

```python
name = models.CharField(
    max_length=100,
    db_collation='numeric'
)
```

It also seems possible to specify it at query time.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
Collation definitely seems like the path forward, if it proves feasible. I recall going down this road a bit in the past but ran into limitations on older PostgreSQL versions. Django 4.2 drops support for PostgreSQL 11 (see #12237), so maybe that will free us up some?

Commit message: 11279 refactor VirtualDisk

Patch to review:
diff --git a/netbox/circuits/migrations/0049_alter_provider_name_alter_providernetwork_name.py b/netbox/circuits/migrations/0049_alter_provider_name_alter_providernetwork_name.py
new file mode 100644
index 00000000000..ded72ae8fd6
--- /dev/null
+++ b/netbox/circuits/migrations/0049_alter_provider_name_alter_providernetwork_name.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.0.9 on 2024-11-13 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0048_circuitterminations_cached_relations'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 f0fe77b1acf..be81caa549f 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 c58968ee8ab..4914dbaf37a 100644
--- a/netbox/core/tests/test_changelog.py
+++ b/netbox/core/tests/test_changelog.py
@@ -76,10 +76,6 @@ def test_create_object(self):
         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 @@ def test_update_object(self):
         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 @@ def test_delete_object(self):
         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 @@ def test_create_object(self):
         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 @@ def test_update_object(self):
         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 @@ def test_delete_object(self):
         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 6493ec6b1e3..fc5f35780c3 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 @@ def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.typ
     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 @@ def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", straw
     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.py b/netbox/dcim/migrations/0197_natural_sort.py
new file mode 100644
index 00000000000..ccc7d1457fe
--- /dev/null
+++ b/netbox/dcim/migrations/0197_natural_sort.py
@@ -0,0 +1,326 @@
+# Generated by Django 5.0.9 on 2024-11-12 22:21
+
+from django.contrib.postgres.operations import CreateCollation
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0196_qinq_svlan'),
+    ]
+
+    operations = [
+        CreateCollation(
+            "natural_sort",
+            provider="icu",
+            locale="und-u-kn-true",
+        ),
+        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 00555d49e1b..ddd4d242674 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 36fd02add46..31278a13c7c 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 47f4ee6c9e5..a836c5d373c 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -23,7 +23,7 @@
 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 d0c6b18b664..284cfe83254 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 013dfb61948..08b7f5a35ad 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -19,7 +19,7 @@
 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 a290f4119fe..0985a8d7af6 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -8,7 +8,6 @@
 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 fed33401c3e..41a45829ee1 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -132,7 +132,6 @@ class Meta(NetBoxTable.Meta):
 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='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -433,7 +430,6 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
         verbose_name=_('Name'),
         template_code='<i class="mdi mdi-console-network-outline"></i> '
                       '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -482,7 +478,6 @@ class DevicePowerPortTable(PowerPortTable):
         verbose_name=_('Name'),
         template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
                       '{{ value }}</a>',
-        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='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -736,7 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
         verbose_name=_('Name'),
         template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
                       '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -783,7 +776,6 @@ class DeviceRearPortTable(RearPortTable):
         verbose_name=_('Name'),
         template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
                       '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -846,7 +838,6 @@ class DeviceDeviceBayTable(DeviceBayTable):
         verbose_name=_('Name'),
         template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}'
                       '"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
     actions = columns.ActionsColumn(
@@ -915,7 +906,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 +972,6 @@ class DeviceInventoryItemTable(InventoryItemTable):
         verbose_name=_('Name'),
         template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
                       '{{ value }}</a>',
-        order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
     )
 
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index e8a4e35f114..d9e572556b7 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', )
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index a6b704161af..dbd99ca246c 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -111,7 +111,6 @@ class Meta(NetBoxTable.Meta):
 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 9a821a3843e..03915386e9d 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -688,7 +688,7 @@ def get(self, request):
         sort = request.GET.get('sort', 'name')
         if sort not in ORDERING_CHOICES:
             sort = 'name'
-        sort_field = sort.replace("name", "_name")  # Use natural ordering
+        sort_field = sort  # Use natural ordering
         racks = racks.order_by(sort_field)
 
         # Pagination
@@ -731,8 +731,8 @@ def get_extra_context(self, request, instance):
             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_alter_asnrange_name_alter_routetarget_name_and_more.py b/netbox/ipam/migrations/0076_alter_asnrange_name_alter_routetarget_name_and_more.py
new file mode 100644
index 00000000000..40631331b3a
--- /dev/null
+++ b/netbox/ipam/migrations/0076_alter_asnrange_name_alter_routetarget_name_and_more.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.0.9 on 2024-11-13 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0075_vlan_qinq'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 eb47426b231..c1d25130128 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 7832cfc67c5..fa31fd60878 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 26afb7927ca..6a8b8d649b2 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_alter_contact_name_alter_tenant_name_and_more.py b/netbox/tenancy/migrations/0017_alter_contact_name_alter_tenant_name_and_more.py
new file mode 100644
index 00000000000..5c0c9cd24c7
--- /dev/null
+++ b/netbox/tenancy/migrations/0017_alter_contact_name_alter_tenant_name_and_more.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.0.9 on 2024-11-13 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0016_charfield_null_choices'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 24ffef0cf3c..3969c831757 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 7a2d9c2f89b..55f0c5933cf 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 ee71223cb1b..c4ea13bc9f5 100644
--- a/netbox/utilities/fields.py
+++ b/netbox/utilities/fields.py
@@ -5,7 +5,7 @@
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
-from utilities.ordering import naturalize
+from utilities.ordering import naturalize_interface
 from .forms.widgets import ColorSelect
 from .validators import ColorValidator
 
@@ -40,7 +40,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=naturalize_interface, *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 6052c8936ae..8476eac7e66 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_alter_virtualmachine_options_and_more.py b/netbox/virtualization/migrations/0046_alter_virtualmachine_options_and_more.py
new file mode 100644
index 00000000000..a8080d540c5
--- /dev/null
+++ b/netbox/virtualization/migrations/0046_alter_virtualmachine_options_and_more.py
@@ -0,0 +1,45 @@
+# Generated by Django 5.0.9 on 2024-11-13 16:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0045_clusters_cached_relations'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 601ee7f23ec..9f7b97e2983 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 4ee41e403ec..7e048b950f8 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',
@@ -271,16 +267,6 @@ class ComponentModel(NetBoxModel):
         on_delete=models.CASCADE,
         related_name='%(class)ss'
     )
-    name = models.CharField(
-        verbose_name=_('name'),
-        max_length=64
-    )
-    _name = NaturalOrderingField(
-        target_field='name',
-        naturalize_function=naturalize_interface,
-        max_length=100,
-        blank=True
-    )
     description = models.CharField(
         verbose_name=_('description'),
         max_length=200,
@@ -289,7 +275,6 @@ class ComponentModel(NetBoxModel):
 
     class Meta:
         abstract = True
-        ordering = ('virtual_machine', CollateAsChar('_name'))
         constraints = (
             models.UniqueConstraint(
                 fields=('virtual_machine', 'name'),
@@ -311,6 +296,16 @@ def parent_object(self):
 
 
 class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=64,
+    )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
     virtual_machine = models.ForeignKey(
         to='virtualization.VirtualMachine',
         on_delete=models.CASCADE,
@@ -358,6 +353,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()
@@ -409,6 +405,11 @@ def l2vpn_termination(self):
 
 
 class VirtualDisk(ComponentModel, TrackingModelMixin):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=64,
+        db_collation="natural_sort"
+    )
     size = models.PositiveIntegerField(
         verbose_name=_('size (MB)'),
     )
@@ -416,3 +417,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 4a31387114f..26d32f8cf09 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -53,7 +53,6 @@
 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_alter_ikepolicy_name_alter_ikeproposal_name_and_more.py b/netbox/vpn/migrations/0007_alter_ikepolicy_name_alter_ikeproposal_name_and_more.py
new file mode 100644
index 00000000000..a22eef1e536
--- /dev/null
+++ b/netbox/vpn/migrations/0007_alter_ikepolicy_name_alter_ikeproposal_name_and_more.py
@@ -0,0 +1,49 @@
+# Generated by Django 5.0.9 on 2024-11-13 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0006_charfield_null_choices'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 2b721ec29b0..8e991b57869 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 b799ab32d3f..3e562531d42 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 3a0f1dc1458..714024a81a3 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_alter_wirelesslangroup_name.py b/netbox/wireless/migrations/0012_alter_wirelesslangroup_name.py
new file mode 100644
index 00000000000..c776145e530
--- /dev/null
+++ b/netbox/wireless/migrations/0012_alter_wirelesslangroup_name.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.0.9 on 2024-11-13 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'),
+        ('dcim', '0197_natural_sort'),
+    ]
+
+    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 d78c893a67d..61ff72bc1d3 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'),


Code review comments:
path: netbox/circuits/migrations/0049_alter_provider_name_alter_providernetwork_name.py
text: @user1:
Let's use a simple, consistent name (e.g. `natural_ordering` or similar) for these migrations across all apps, so that it's immediately clear what they're doing.

diff_hunk: @@ -688,7 +688,7 @@ def get(self, request):
         sort = request.GET.get('sort', 'name')
         if sort not in ORDERING_CHOICES:
             sort = 'name'
-        sort_field = sort.replace("name", "_name")  # Use natural ordering
+        sort_field = sort  # Use natural ordering
         racks = racks.order_by(sort_field)
path: netbox/dcim/views.py
text: @user1:
```suggestion
        racks = racks.order_by(sort)
```

diff_hunk: @@ -40,7 +40,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=naturalize_interface, *args, **kwargs):
path: netbox/utilities/fields.py
text: @user1:
Should this be a required argument instead of defaulting to `naturalize_interface()`?

diff_hunk: @@ -409,10 +405,16 @@ def l2vpn_termination(self):
 
 
 class VirtualDisk(ComponentModel, TrackingModelMixin):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=64,
+        db_collation="natural_sort"
+    )
path: netbox/virtualization/models/virtualmachines.py
text: @user1:
We might want to put this under ComponentModel instead; I'd expect it to apply for any future VM components as well. Your call.

diff_hunk: @@ -0,0 +1,326 @@
+# Generated by Django 5.0.9 on 2024-11-12 22:21
+
+from django.contrib.postgres.operations import CreateCollation
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0196_qinq_svlan'),
+    ]
+
+    operations = [
+        CreateCollation(
+            "natural_sort",
+            provider="icu",
+            locale="und-u-kn-true",
+        ),
path: netbox/dcim/migrations/0197_natural_sort.py
text: @user1:
It might be worth splitting this into its own migration that can be explicitly referenced as a dependency by migrations in other apps, for clarity.