Parcourir la source

#21488 - Replace MPTT wtih PostgreSQL Ltree

Arthur il y a 1 jour
Parent
commit
53ee617915
52 fichiers modifiés avec 1077 ajouts et 345 suppressions
  1. 0 3
      base_requirements.txt
  2. 4 4
      netbox/core/models/change_logging.py
  3. 7 7
      netbox/dcim/api/views.py
  4. 8 8
      netbox/dcim/graphql/types.py
  5. 4 5
      netbox/dcim/migrations/0002_squashed.py
  6. 1 2
      netbox/dcim/migrations/0131_squashed_0159.py
  7. 1 2
      netbox/dcim/migrations/0190_nested_modules.py
  8. 3 12
      netbox/dcim/migrations/0191_module_bay_rebuild.py
  9. 1 2
      netbox/dcim/migrations/0203_device_role_nested.py
  10. 4 12
      netbox/dcim/migrations/0204_device_role_rebuild.py
  11. 1 2
      netbox/dcim/migrations/0213_platform_parent.py
  12. 3 18
      netbox/dcim/migrations/0214_platform_rebuild.py
  13. 3 22
      netbox/dcim/migrations/0226_modulebay_rebuild_tree.py
  14. 17 0
      netbox/dcim/migrations/0234_enable_ltree_extension.py
  15. 137 0
      netbox/dcim/migrations/0235_ltree_paths.py
  16. 7 5
      netbox/dcim/models/device_component_templates.py
  17. 14 16
      netbox/dcim/models/device_components.py
  18. 7 6
      netbox/dcim/models/devices.py
  19. 12 24
      netbox/dcim/models/modules.py
  20. 10 9
      netbox/dcim/models/sites.py
  21. 7 21
      netbox/extras/querysets.py
  22. 0 19
      netbox/netbox/api/viewsets/__init__.py
  23. 0 11
      netbox/netbox/constants.py
  24. 9 6
      netbox/netbox/graphql/filter_lookups.py
  25. 9 14
      netbox/netbox/models/__init__.py
  26. 417 0
      netbox/netbox/models/ltree.py
  27. 0 1
      netbox/netbox/settings.py
  28. 7 2
      netbox/netbox/tables/columns.py
  29. 1 1
      netbox/netbox/tables/tables.py
  30. 4 21
      netbox/netbox/views/generic/bulk_views.py
  31. 0 1
      netbox/templates/dcim/module.html
  32. 3 3
      netbox/tenancy/api/views.py
  33. 2 2
      netbox/tenancy/graphql/types.py
  34. 1 2
      netbox/tenancy/migrations/0001_squashed_0012.py
  35. 1 2
      netbox/tenancy/migrations/0002_squashed_0011.py
  36. 17 0
      netbox/tenancy/migrations/0025_enable_ltree_extension.py
  37. 74 0
      netbox/tenancy/migrations/0026_ltree_paths.py
  38. 10 10
      netbox/tenancy/models/contacts.py
  39. 4 3
      netbox/tenancy/models/tenants.py
  40. 0 24
      netbox/utilities/mptt.py
  41. 4 3
      netbox/utilities/query.py
  42. 0 21
      netbox/utilities/templatetags/mptt.py
  43. 4 7
      netbox/utilities/testing/filtersets.py
  44. 4 3
      netbox/utilities/tests/test_filters.py
  45. 174 0
      netbox/utilities/tests/test_ltree.py
  46. 2 2
      netbox/wireless/api/views.py
  47. 1 1
      netbox/wireless/graphql/types.py
  48. 1 2
      netbox/wireless/migrations/0001_squashed_0008.py
  49. 17 0
      netbox/wireless/migrations/0020_enable_ltree_extension.py
  50. 56 0
      netbox/wireless/migrations/0021_ltree_paths.py
  51. 4 3
      netbox/wireless/models.py
  52. 0 1
      requirements.txt

+ 0 - 3
base_requirements.txt

@@ -26,9 +26,6 @@ django-graphiql-debug-toolbar
 # https://django-htmx.readthedocs.io/en/latest/changelog.html
 django-htmx
 
-# Modified Preorder Tree Traversal (recursive nesting of objects)
-django-mptt
-
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 django-pglocks

+ 4 - 4
netbox/core/models/change_logging.py

@@ -6,7 +6,6 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
 
 from core.choices import ObjectChangeActionChoices
 from core.querysets import ObjectChangeQuerySet
@@ -164,9 +163,10 @@ class ObjectChange(models.Model):
         if issubclass(model, ChangeLoggingMixin):
             attrs.update({'created', 'last_updated'})
 
-        # Exclude MPTT-internal fields
-        if issubclass(model, MPTTModel):
-            attrs.update({'level', 'lft', 'rght', 'tree_id'})
+        # Exclude trigger-maintained ltree path
+        from netbox.models.ltree import LtreeModel
+        if issubclass(model, LtreeModel):
+            attrs.update({'path'})
 
         return attrs
 

+ 7 - 7
netbox/dcim/api/views.py

@@ -16,7 +16,7 @@ from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
-from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
 from utilities.query import count_related
@@ -95,7 +95,7 @@ class PassThroughPortMixin:
 # Regions
 #
 
-class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class RegionViewSet(NetBoxModelViewSet):
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Site,
@@ -111,7 +111,7 @@ class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 # Site groups
 #
 
-class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class SiteGroupViewSet(NetBoxModelViewSet):
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
         Site,
@@ -137,7 +137,7 @@ class SiteViewSet(NetBoxModelViewSet):
 # Locations
 #
 
-class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class LocationViewSet(NetBoxModelViewSet):
     queryset = Location.objects.add_related_count(
         Location.objects.add_related_count(
             Location.objects.all(),
@@ -356,7 +356,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
-class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class InventoryItemTemplateViewSet(NetBoxModelViewSet):
     queryset = InventoryItemTemplate.objects.all()
     serializer_class = serializers.InventoryItemTemplateSerializer
     filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -388,7 +388,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 # Platforms
 #
 
-class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class PlatformViewSet(NetBoxModelViewSet):
     queryset = Platform.objects.add_related_count(
         Platform.objects.add_related_count(
             Platform.objects.all(),
@@ -543,7 +543,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceBayFilterSet
 
 
-class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class InventoryItemViewSet(NetBoxModelViewSet):
     queryset = InventoryItem.objects.all()
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet

+ 8 - 8
netbox/dcim/graphql/types.py

@@ -319,7 +319,7 @@ class DeviceBayTemplateType(ComponentTemplateType):
 
 @strawberry_django.type(
     models.InventoryItemTemplate,
-    exclude=['component_type', 'component_id', 'parent'],
+    exclude=['component_type', 'component_id', 'parent', 'path'],
     filters=InventoryItemTemplateFilter,
     pagination=True
 )
@@ -347,7 +347,7 @@ class InventoryItemTemplateType(ComponentTemplateType):
 
 @strawberry_django.type(
     models.DeviceRole,
-    fields='__all__',
+    exclude=['path'],
     filters=DeviceRoleFilter,
     pagination=True
 )
@@ -484,7 +484,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
 
 @strawberry_django.type(
     models.InventoryItem,
-    exclude=['component_type', 'component_id', 'parent'],
+    exclude=['component_type', 'component_id', 'parent', 'path'],
     filters=InventoryItemFilter,
     pagination=True
 )
@@ -526,7 +526,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
 @strawberry_django.type(
     models.Location,
     # fields='__all__',
-    exclude=['parent'],  # bug - temp
+    exclude=['parent', 'path'],  # bug - temp
     filters=LocationFilter,
     pagination=True
 )
@@ -590,7 +590,7 @@ class ModuleType(PrimaryObjectType):
 @strawberry_django.type(
     models.ModuleBay,
     # fields='__all__',
-    exclude=['parent'],
+    exclude=['parent', 'path'],
     filters=ModuleBayFilter,
     pagination=True
 )
@@ -647,7 +647,7 @@ class ModuleTypeType(PrimaryObjectType):
 
 @strawberry_django.type(
     models.Platform,
-    fields='__all__',
+    exclude=['path'],
     filters=PlatformFilter,
     pagination=True
 )
@@ -855,7 +855,7 @@ class RearPortTemplateType(ModularComponentTemplateType):
 
 @strawberry_django.type(
     models.Region,
-    exclude=['parent'],
+    exclude=['parent', 'path'],
     filters=RegionFilter,
     pagination=True
 )
@@ -916,7 +916,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
 
 @strawberry_django.type(
     models.SiteGroup,
-    exclude=['parent'],  # bug - temp
+    exclude=['parent', 'path'],  # bug - temp
     filters=SiteGroupFilter,
     pagination=True
 )

+ 4 - 5
netbox/dcim/migrations/0002_squashed.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 from django.conf import settings
 from django.db import migrations, models
@@ -27,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='sitegroup',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -76,7 +75,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='region',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -381,7 +380,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='location',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -417,7 +416,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='inventoryitem',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,

+ 1 - 2
netbox/dcim/migrations/0131_squashed_0159.py

@@ -1,6 +1,5 @@
 import django.core.validators
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 from django.db import migrations, models
 
@@ -1248,7 +1247,7 @@ class Migration(migrations.Migration):
                 ),
                 (
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,

+ 1 - 2
netbox/dcim/migrations/0190_nested_modules.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 from django.db import migrations, models
 
 
@@ -44,7 +43,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='modulebay',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 editable=False,
                 null=True,

+ 3 - 12
netbox/dcim/migrations/0191_module_bay_rebuild.py

@@ -1,22 +1,13 @@
-import mptt
-import mptt.managers
 from django.db import migrations
 
 
-def rebuild_mptt(apps, schema_editor):
-    manager = mptt.managers.TreeManager()
-    ModuleBay = apps.get_model('dcim', 'ModuleBay')
-    manager.model = ModuleBay
-    mptt.register(ModuleBay)
-    manager.contribute_to_class(ModuleBay, 'objects')
-    manager.rebuild()
-
-
 class Migration(migrations.Migration):
     dependencies = [
         ('dcim', '0190_nested_modules'),
     ]
 
+    # Historical MPTT rebuild: now a no-op. Tree state will be populated from
+    # parent FKs into an ltree path column by a later migration.
     operations = [
-        migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
+        migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop),
     ]

+ 1 - 2
netbox/dcim/migrations/0203_device_role_nested.py

@@ -1,7 +1,6 @@
 # Generated by Django 5.1.7 on 2025-03-25 18:06
 
 import django.db.models.manager
-import mptt.fields
 from django.db import migrations, models
 
 
@@ -39,7 +38,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='devicerole',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,

+ 4 - 12
netbox/dcim/migrations/0204_device_role_rebuild.py

@@ -1,22 +1,14 @@
-import mptt
-import mptt.managers
 from django.db import migrations
 
 
-def rebuild_mptt(apps, schema_editor):
-    manager = mptt.managers.TreeManager()
-    DeviceRole = apps.get_model('dcim', 'DeviceRole')
-    manager.model = DeviceRole
-    mptt.register(DeviceRole)
-    manager.contribute_to_class(DeviceRole, 'objects')
-    manager.rebuild()
-
-
 class Migration(migrations.Migration):
     dependencies = [
         ('dcim', '0203_device_role_nested'),
     ]
 
+    # Historical MPTT rebuild: now a no-op. The legacy lft/rght/tree_id/level
+    # columns are removed in a later migration and ltree paths are populated
+    # from parent FKs after that.
     operations = [
-        migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
+        migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop),
     ]

+ 1 - 2
netbox/dcim/migrations/0213_platform_parent.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 from django.db import migrations, models
 
 
@@ -14,7 +13,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='platform',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,

+ 3 - 18
netbox/dcim/migrations/0214_platform_rebuild.py

@@ -1,29 +1,14 @@
-import mptt
-import mptt.managers
 from django.db import migrations
 
 
-def rebuild_mptt(apps, schema_editor):
-    """
-    Construct the MPTT hierarchy.
-    """
-    Platform = apps.get_model('dcim', 'Platform')
-    manager = mptt.managers.TreeManager()
-    manager.model = Platform
-    mptt.register(Platform)
-    manager.contribute_to_class(Platform, 'objects')
-    manager.rebuild()
-
-
 class Migration(migrations.Migration):
 
     dependencies = [
         ('dcim', '0213_platform_parent'),
     ]
 
+    # Historical MPTT rebuild: now a no-op. Tree state will be populated from
+    # parent FKs into an ltree path column by a later migration.
     operations = [
-        migrations.RunPython(
-            code=rebuild_mptt,
-            reverse_code=migrations.RunPython.noop
-        ),
+        migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop),
     ]

+ 3 - 22
netbox/dcim/migrations/0226_modulebay_rebuild_tree.py

@@ -1,32 +1,13 @@
-import mptt.managers
-import mptt.models
 from django.db import migrations
 
 
-def rebuild_mptt(apps, schema_editor):
-    """
-    Rebuild the MPTT tree for ModuleBay to apply new ordering.
-    """
-    ModuleBay = apps.get_model('dcim', 'ModuleBay')
-
-    # Set MPTTMeta with the correct order_insertion_by
-    class MPTTMeta:
-        order_insertion_by = ('name',)
-
-    ModuleBay.MPTTMeta = MPTTMeta
-    ModuleBay._mptt_meta = mptt.models.MPTTOptions(MPTTMeta)
-
-    manager = mptt.managers.TreeManager()
-    manager.model = ModuleBay
-    manager.contribute_to_class(ModuleBay, 'objects')
-    manager.rebuild()
-
-
 class Migration(migrations.Migration):
     dependencies = [
         ('dcim', '0226_add_mptt_tree_indexes'),
     ]
 
+    # Historical MPTT rebuild: now a no-op. The MPTT tree columns are removed
+    # by a later migration and ltree paths are populated from parent FKs.
     operations = [
-        migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
+        migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop),
     ]

+ 17 - 0
netbox/dcim/migrations/0234_enable_ltree_extension.py

@@ -0,0 +1,17 @@
+from django.contrib.postgres.operations import CreateExtension
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    """
+    Enable the PostgreSQL ltree extension. Required by the next migration which
+    adds the `path` column on hierarchical models.
+    """
+
+    dependencies = [
+        ('dcim', '0233_device_render_config_permission'),
+    ]
+
+    operations = [
+        CreateExtension('ltree'),
+    ]

+ 137 - 0
netbox/dcim/migrations/0235_ltree_paths.py

@@ -0,0 +1,137 @@
+"""
+Replace django-mptt with PostgreSQL ltree for dcim's hierarchical models.
+
+For each of (Region, SiteGroup, Location, DeviceRole, Platform, ModuleBay,
+InventoryItem, InventoryItemTemplate) this migration:
+
+1. Adds a nullable `path` LTreeField.
+2. Installs per-table BEFORE-INSERT/UPDATE-OF-parent_id and AFTER-UPDATE-OF-path
+   triggers so concurrent writes during the long-running data step get correct
+   paths.
+3. Populates paths for existing rows via a single recursive CTE per table.
+4. Tightens `path` to NOT NULL.
+5. Drops the legacy MPTT columns (lft, rght, tree_id, level).
+6. Adds a GiST index on the `path` column for efficient `@>` / `<@` lookups.
+"""
+from django.contrib.postgres.indexes import GistIndex
+from django.db import migrations
+
+import netbox.models.ltree
+from netbox.models.ltree import InstallLtreeTriggers
+
+MODELS = (
+    'region', 'sitegroup', 'location', 'devicerole', 'platform',
+    'inventoryitem', 'inventoryitemtemplate', 'modulebay',
+)
+
+TABLES = (
+    'dcim_region',
+    'dcim_sitegroup',
+    'dcim_location',
+    'dcim_devicerole',
+    'dcim_platform',
+    'dcim_inventoryitem',
+    'dcim_inventoryitemtemplate',
+    'dcim_modulebay',
+)
+
+LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
+
+
+def _populate_paths_sql():
+    blocks = []
+    for table in TABLES:
+        blocks.append(f"""
+WITH RECURSIVE t(id, parent_id, path) AS (
+    SELECT id, parent_id, id::text::ltree FROM "{table}" WHERE parent_id IS NULL
+    UNION ALL
+    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    FROM "{table}" r JOIN t ON r.parent_id = t.id
+)
+UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
+""")
+    return '\n'.join(blocks)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0234_enable_ltree_extension'),
+    ]
+
+    operations = [
+        # 1. Add nullable path column
+        *[
+            migrations.AddField(
+                model_name=m,
+                name='path',
+                field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
+            )
+            for m in MODELS
+        ],
+
+        # 2. Install path-maintenance triggers
+        *[InstallLtreeTriggers(t) for t in TABLES],
+
+        # 3. Populate existing rows
+        migrations.RunSQL(_populate_paths_sql(), reverse_sql=migrations.RunSQL.noop),
+
+        # 4. Tighten to NOT NULL with empty-string default
+        *[
+            migrations.AlterField(
+                model_name=m,
+                name='path',
+                field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
+            )
+            for m in MODELS
+        ],
+
+        # 5. Drop legacy (tree_id, lft) indexes added in 0226_add_mptt_tree_indexes,
+        # then drop the legacy MPTT columns.
+        migrations.RemoveIndex(model_name='devicerole', name='dcim_devicerole_tree_id_lfbf11'),
+        migrations.RemoveIndex(model_name='inventoryitem', name='dcim_inventoryitem_tree_id975c'),
+        migrations.RemoveIndex(model_name='inventoryitemtemplate', name='dcim_inventoryitemtemplatedee0'),
+        migrations.RemoveIndex(model_name='location', name='dcim_location_tree_id_lft_idx'),
+        migrations.RemoveIndex(model_name='modulebay', name='dcim_modulebay_tree_id_lft_idx'),
+        migrations.RemoveIndex(model_name='platform', name='dcim_platform_tree_id_lft_idx'),
+        migrations.RemoveIndex(model_name='region', name='dcim_region_tree_id_lft_idx'),
+        migrations.RemoveIndex(model_name='sitegroup', name='dcim_sitegroup_tree_id_lft_idx'),
+        *[
+            migrations.RemoveField(model_name=m, name=f)
+            for m in MODELS for f in LEGACY_FIELDS
+        ],
+
+        # 6. Add GiST indexes on path
+        migrations.AddIndex(
+            model_name='region',
+            index=GistIndex(fields=['path'], name='dcim_region_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='sitegroup',
+            index=GistIndex(fields=['path'], name='dcim_sitegroup_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='location',
+            index=GistIndex(fields=['path'], name='dcim_location_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='devicerole',
+            index=GistIndex(fields=['path'], name='dcim_devicerole_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='platform',
+            index=GistIndex(fields=['path'], name='dcim_platform_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='inventoryitem',
+            index=GistIndex(fields=['path'], name='dcim_inventoryitem_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='inventoryitemtemplate',
+            index=GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='modulebay',
+            index=GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
+        ),
+    ]

+ 7 - 5
netbox/dcim/models/device_component_templates.py

@@ -1,9 +1,9 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 from dcim.choices import *
 from dcim.constants import *
@@ -11,8 +11,8 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
+from netbox.models.ltree import LtreeManager, LtreeModel
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.tracking import TrackingModelMixin
 from wireless.choices import WirelessRoleChoices
@@ -812,11 +812,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
         }
 
 
-class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
+class InventoryItemTemplate(LtreeModel, ComponentTemplateModel):
     """
     A template for an InventoryItem to be created for a new parent Device.
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         on_delete=models.CASCADE,
         related_name='child_items',
@@ -860,13 +860,15 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
         help_text=_('Manufacturer-assigned part identifier')
     )
 
-    objects = TreeManager()
     component_model = InventoryItem
 
+    objects = LtreeManager()
+
     class Meta:
         ordering = ('device_type__id', 'parent__id', 'name')
         indexes = (
             models.Index(fields=('component_type', 'component_id')),
+            GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'),
         )
         constraints = (
             models.UniqueConstraint(

+ 14 - 16
netbox/dcim/models/device_components.py

@@ -2,11 +2,11 @@ from functools import cached_property
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 from dcim.choices import *
 from dcim.constants import *
@@ -15,9 +15,9 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
+from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.mixins import OwnerMixin
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.query_functions import CollateAsChar
 from utilities.tracking import TrackingModelMixin
@@ -1313,11 +1313,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 # Bays
 #
 
-class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
+class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
     """
     An empty space within a Device which can house a child device
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         on_delete=models.CASCADE,
         related_name='children',
@@ -1337,14 +1337,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         default=True,
     )
 
-    objects = TreeManager()
-
     clone_fields = ('device', 'enabled')
 
+    objects = LtreeManager()
+
     class Meta(ModularComponentModel.Meta):
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('device', 'module', 'name'),
@@ -1354,9 +1354,6 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         verbose_name = _('module bay')
         verbose_name_plural = _('module bays')
 
-    class MPTTMeta:
-        order_insertion_by = ('name',)
-
     def clean(self):
         super().clean()
 
@@ -1469,12 +1466,12 @@ class InventoryItemRole(OrganizationalModel):
         verbose_name_plural = _('inventory item roles')
 
 
-class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
+class InventoryItem(LtreeModel, ComponentModel, TrackingModelMixin):
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     InventoryItems are used only for inventory purposes.
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         on_delete=models.CASCADE,
         related_name='child_items',
@@ -1542,14 +1539,15 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         help_text=_('This item was automatically discovered')
     )
 
-    objects = TreeManager()
-
     clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
 
+    objects = LtreeManager()
+
     class Meta:
         ordering = ('device__id', 'parent__id', 'name')
         indexes = (
             models.Index(fields=('component_type', 'component_id')),
+            GistIndex(fields=['path'], name='dcim_inventoryitem_path_gist'),
         )
         constraints = (
             models.UniqueConstraint(

+ 7 - 6
netbox/dcim/models/devices.py

@@ -4,6 +4,7 @@ from functools import cached_property
 import yaml
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -412,9 +413,9 @@ class DeviceRole(NestedGroupModel):
 
     class Meta:
         ordering = ('name',)
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_devicerole_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -466,9 +467,9 @@ class Platform(NestedGroupModel):
 
     class Meta:
         ordering = ('name',)
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_platform_path_gist'),
+        )
         verbose_name = _('platform')
         verbose_name_plural = _('platforms')
         constraints = (

+ 12 - 24
netbox/dcim/models/modules.py

@@ -5,7 +5,6 @@ from django.db import models
 from django.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
-from mptt.models import MPTTModel
 
 from dcim.choices import *
 from dcim.utils import create_port_mappings, update_interface_bridges
@@ -352,29 +351,22 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
                 component._location = self.device.location
                 component._rack = self.device.rack
 
-            # we handle create and update separately - this is for create
-            if not issubclass(component_model, MPTTModel):
-                component_model.objects.bulk_create(create_instances)
-                # Emit the post_save signal for each newly created object
-                for component in create_instances:
-                    post_save.send(
-                        sender=component_model,
-                        instance=component,
-                        created=True,
-                        raw=False,
-                        using='default',
-                        update_fields=None
-                    )
-            else:
-                # MPTT models must be saved individually to maintain tree structure
-                for instance in create_instances:
-                    instance.save()
+            # Bulk-create new instances. For ltree-backed models (ModuleBay,
+            # InventoryItem), the BEFORE INSERT trigger populates `path` per row.
+            component_model.objects.bulk_create(create_instances)
+            for component in create_instances:
+                post_save.send(
+                    sender=component_model,
+                    instance=component,
+                    created=True,
+                    raw=False,
+                    using='default',
+                    update_fields=None
+                )
 
             update_fields = ['module']
 
-            # we handle create and update separately - this is for update
             component_model.objects.bulk_update(update_instances, update_fields)
-            # Emit the post_save signal for each updated object
             for component in update_instances:
                 post_save.send(
                     sender=component_model,
@@ -385,10 +377,6 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
                     update_fields=update_fields
                 )
 
-            # Rebuild MPTT tree if needed (bulk_update bypasses model save)
-            if issubclass(component_model, MPTTModel) and update_instances:
-                component_model.objects.rebuild()
-
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
 

+ 10 - 9
netbox/dcim/models/sites.py

@@ -3,6 +3,7 @@ import decimal
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
+from django.contrib.postgres.indexes import GistIndex
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from timezone_field import TimeZoneField
@@ -44,9 +45,9 @@ class Region(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_region_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -103,9 +104,9 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_sitegroup_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -324,9 +325,9 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
 
     class Meta:
         ordering = ['site', 'name']
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='dcim_location_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),

+ 7 - 21
netbox/extras/querysets.py

@@ -130,10 +130,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         if self.model._meta.model_name == 'device':
             base_query.add(
                 (Q(
-                    locations__tree_id=OuterRef('location__tree_id'),
-                    locations__level__lte=OuterRef('location__level'),
-                    locations__lft__lte=OuterRef('location__lft'),
-                    locations__rght__gte=OuterRef('location__rght'),
+                    locations__path__ancestor=OuterRef('location__path'),
                 ) | Q(locations=None)),
                 Q.AND
             )
@@ -142,40 +139,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
             base_query.add(Q(locations=None), Q.AND)
             base_query.add(Q(device_types=None), Q.AND)
 
-        # MPTT-based filters
+        # Ltree-based filters: the ConfigContext-side tree node must be an ancestor
+        # (or equal to) the device/VM-side tree node, i.e. `cc_node.path @> obj_node.path`.
         base_query.add(
             (Q(
-                regions__tree_id=OuterRef('site__region__tree_id'),
-                regions__level__lte=OuterRef('site__region__level'),
-                regions__lft__lte=OuterRef('site__region__lft'),
-                regions__rght__gte=OuterRef('site__region__rght'),
+                regions__path__ancestor=OuterRef('site__region__path'),
             ) | Q(regions=None)),
             Q.AND
         )
         base_query.add(
             (Q(
-                site_groups__tree_id=OuterRef('site__group__tree_id'),
-                site_groups__level__lte=OuterRef('site__group__level'),
-                site_groups__lft__lte=OuterRef('site__group__lft'),
-                site_groups__rght__gte=OuterRef('site__group__rght'),
+                site_groups__path__ancestor=OuterRef('site__group__path'),
             ) | Q(site_groups=None)),
             Q.AND
         )
         base_query.add(
             (Q(
-                roles__tree_id=OuterRef('role__tree_id'),
-                roles__level__lte=OuterRef('role__level'),
-                roles__lft__lte=OuterRef('role__lft'),
-                roles__rght__gte=OuterRef('role__rght'),
+                roles__path__ancestor=OuterRef('role__path'),
             ) | Q(roles=None)),
             Q.AND
         )
         base_query.add(
             (Q(
-                platforms__tree_id=OuterRef('platform__tree_id'),
-                platforms__level__lte=OuterRef('platform__level'),
-                platforms__lft__lte=OuterRef('platform__lft'),
-                platforms__rght__gte=OuterRef('platform__rght'),
+                platforms__path__ancestor=OuterRef('platform__path'),
             ) | Q(platforms=None)),
             Q.AND
         )

+ 0 - 19
netbox/netbox/api/viewsets/__init__.py

@@ -4,14 +4,12 @@ from functools import cached_property
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
-from django_pglocks import advisory_lock
 from rest_framework import mixins as drf_mixins
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
 from netbox.api.serializers.features import ChangeLogMessageSerializer
-from netbox.constants import ADVISORY_LOCK_KEYS
 from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
 from utilities.exceptions import AbortRequest, PreconditionFailed
 from utilities.query import reapply_model_ordering
@@ -337,20 +335,3 @@ class NetBoxModelViewSet(
             raise PermissionDenied()
 
 
-class MPTTLockedMixin:
-    """
-    Puts pglock on objects that derive from MPTTModel for parallel API calling.
-    Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
-    """
-
-    def create(self, request, *args, **kwargs):
-        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
-            return super().create(request, *args, **kwargs)
-
-    def update(self, request, *args, **kwargs):
-        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
-            return super().update(request, *args, **kwargs)
-
-    def destroy(self, request, *args, **kwargs):
-        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
-            return super().destroy(request, *args, **kwargs)

+ 0 - 11
netbox/netbox/constants.py

@@ -29,17 +29,6 @@ ADVISORY_LOCK_KEYS = {
     'available-vlans': 100300,
     'available-asns': 100400,
 
-    # MPTT locks
-    'region': 105100,
-    'sitegroup': 105200,
-    'location': 105300,
-    'tenantgroup': 105400,
-    'contactgroup': 105500,
-    'wirelesslangroup': 105600,
-    'inventoryitem': 105700,
-    'inventoryitemtemplate': 105800,
-    'platform': 105900,
-
     # Jobs
     'job-schedules': 110100,
 }

+ 9 - 6
netbox/netbox/graphql/filter_lookups.py

@@ -205,25 +205,28 @@ class TreeNodeFilter:
 
 def generate_tree_node_q_filter(model_class, filter_value: TreeNodeFilter) -> Q:
     """
-    Generate appropriate Q filter for MPTT tree filtering based on match type
+    Generate Q filter for ltree-backed hierarchical models based on match type.
     """
     try:
         node = model_class.objects.get(id=filter_value.id)
     except model_class.DoesNotExist:
         return Q(pk__in=[])
 
+    if not getattr(node, 'path', None):
+        return Q(id=filter_value.id)
+
     if filter_value.match_type == TreeNodeMatch.EXACT:
         return Q(id=filter_value.id)
     if filter_value.match_type == TreeNodeMatch.DESCENDANTS:
-        return Q(tree_id=node.tree_id, lft__gt=node.lft, rght__lt=node.rght)
+        return Q(path__descendant=node.path) & ~Q(id=node.id)
     if filter_value.match_type == TreeNodeMatch.SELF_AND_DESCENDANTS:
-        return Q(tree_id=node.tree_id, lft__gte=node.lft, rght__lte=node.rght)
+        return Q(path__descendant_or_equal=node.path)
     if filter_value.match_type == TreeNodeMatch.CHILDREN:
-        return Q(tree_id=node.tree_id, level=node.level + 1, lft__gt=node.lft, rght__lt=node.rght)
+        return Q(parent_id=node.id)
     if filter_value.match_type == TreeNodeMatch.SIBLINGS:
-        return Q(tree_id=node.tree_id, level=node.level, parent=node.parent) & ~Q(id=node.id)
+        return Q(parent_id=node.parent_id) & ~Q(id=node.id)
     if filter_value.match_type == TreeNodeMatch.ANCESTORS:
-        return Q(tree_id=node.tree_id, lft__lt=node.lft, rght__gt=node.rght)
+        return Q(path__ancestor=node.path) & ~Q(id=node.id)
     if filter_value.match_type == TreeNodeMatch.PARENT:
         return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
     return Q()

+ 9 - 14
netbox/netbox/models/__init__.py

@@ -5,11 +5,10 @@ from django.core.validators import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 from netbox.models.features import *
+from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.mixins import OwnerMixin
-from utilities.mptt import TreeManager
 from utilities.querysets import RestrictedQuerySet
 
 __all__ = (
@@ -159,16 +158,12 @@ class PrimaryModel(OwnerMixin, NetBoxModel):
         abstract = True
 
 
-class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
+class NestedGroupModel(OwnerMixin, NetBoxModel, LtreeModel):
     """
     Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
-    recursively using MPTT. Within each parent, each child instance must have a unique name.
-
-    Note: django-mptt injects the (tree_id, lft) index dynamically, but Django's migration autodetector won't
-    detect it unless concrete subclasses explicitly declare Meta.indexes (even as an empty tuple). See #21016
-    and django-mptt/django-mptt#682.
+    recursively using PostgreSQL ltree. Within each parent, each child instance must have a unique name.
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         on_delete=models.CASCADE,
         related_name='children',
@@ -194,13 +189,13 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
         blank=True
     )
 
-    objects = TreeManager()
+    # Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet
+    # default manager via MRO resolution.
+    objects = LtreeManager()
 
     class Meta:
         abstract = True
-
-    class MPTTMeta:
-        order_insertion_by = ('name',)
+        ordering = ('path',)
 
     def __str__(self):
         return self.name
@@ -208,7 +203,7 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
     def clean(self):
         super().clean()
 
-        # An MPTT model cannot be its own parent
+        # A nested group cannot be its own parent or a descendant of itself
         if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True):
             raise ValidationError({
                 "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name)

+ 417 - 0
netbox/netbox/models/ltree.py

@@ -0,0 +1,417 @@
+"""
+Ltree-based hierarchical model support - drop-in replacement for django-mptt.
+
+LtreeModel provides the same public API as django-mptt's MPTTModel (get_ancestors,
+get_descendants, get_children, get_root, get_family, get_siblings,
+get_descendant_count, get_level, level, is_root_node, is_leaf_node, is_child_node,
+move_to, insert_at) backed by a PostgreSQL ltree column.
+
+Paths are maintained entirely by PostgreSQL triggers installed via the
+InstallLtreeTriggers migration operation. The Python layer never computes or
+mutates paths directly; it only reads `path` back from the database after
+inserts and parent_id changes via refresh_from_db(fields=['path']).
+"""
+from django.contrib.contenttypes.models import ContentType
+from django.db import migrations, models
+from django.db.models import Count, ForeignKey, ManyToManyField, Lookup, OuterRef, Q, Subquery
+from django.db.models.expressions import RawSQL
+
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'InstallLtreeTriggers',
+    'LtreeField',
+    'LtreeManager',
+    'LtreeModel',
+    'LtreeQuerySet',
+)
+
+
+#
+# Field
+#
+
+class LtreeField(models.TextField):
+    """
+    Custom field backed by PostgreSQL's ltree type. Stores hierarchical paths
+    such as "1.4.27" (each label is the integer PK of an ancestor).
+    """
+    description = "PostgreSQL ltree field"
+
+    def db_type(self, connection):
+        return 'ltree'
+
+    def get_prep_value(self, value):
+        if value is None:
+            return value
+        return str(value)
+
+
+@LtreeField.register_lookup
+class Ancestor(Lookup):
+    """`path` is an ancestor of (or equal to) the queried path:  path @> rhs"""
+    lookup_name = 'ancestor'
+
+    def as_sql(self, compiler, connection):
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+        return f'{lhs} @> {rhs}', lhs_params + rhs_params
+
+
+@LtreeField.register_lookup
+class Descendant(Lookup):
+    """`path` is a descendant of (or equal to) the queried path:  path <@ rhs"""
+    lookup_name = 'descendant'
+
+    def as_sql(self, compiler, connection):
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+        return f'{lhs} <@ {rhs}', lhs_params + rhs_params
+
+
+@LtreeField.register_lookup
+class DescendantOrEqual(Lookup):
+    """Alias of `descendant`; `<@` is inclusive in PostgreSQL ltree."""
+    lookup_name = 'descendant_or_equal'
+
+    def as_sql(self, compiler, connection):
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+        return f'{lhs} <@ {rhs}', lhs_params + rhs_params
+
+
+#
+# QuerySet / Manager
+#
+
+class LtreeQuerySet(RestrictedQuerySet):
+    """QuerySet for ltree-based hierarchies, layered on RestrictedQuerySet."""
+
+    def add_related_count(self, queryset, model, rel_field, count_attr, cumulative=False):
+        """
+        Annotate `queryset` with the count of `model` instances related via
+        `rel_field`, mirroring django-mptt's `TreeManager.add_related_count`.
+
+        When `cumulative=True`, counts include rows pointing to any descendant
+        (using the ltree `<@` operator against the parent's `path`). Handles
+        ForeignKey, ManyToManyField, and the NetBox GenericForeignKey "scope"
+        pattern (scope_type / scope_id).
+        """
+        has_direct_fk = False
+        is_many_to_many = False
+        try:
+            field = model._meta.get_field(rel_field)
+            if isinstance(field, ManyToManyField):
+                is_many_to_many = True
+            elif isinstance(field, ForeignKey):
+                has_direct_fk = True
+        except Exception:
+            pass
+
+        has_generic_fk = (
+            hasattr(model, 'scope_type') and hasattr(model, 'scope_id')
+            and not has_direct_fk and not is_many_to_many
+        )
+
+        parent_table = queryset.model._meta.db_table
+        related_table = model._meta.db_table
+
+        if cumulative:
+            if is_many_to_many:
+                field = model._meta.get_field(rel_field)
+                m2m_table = field.remote_field.through._meta.db_table
+                m2m_parent_col = field.m2m_column_name()
+                m2m_related_col = field.m2m_reverse_name()
+                sql = f'''(
+                    SELECT COUNT(DISTINCT "{related_table}"."id")
+                    FROM "{related_table}"
+                    INNER JOIN "{m2m_table}"
+                      ON "{related_table}"."id" = "{m2m_table}"."{m2m_related_col}"
+                    INNER JOIN "{parent_table}" AS subtree
+                      ON "{m2m_table}"."{m2m_parent_col}" = subtree."id"
+                    WHERE subtree."path" <@ "{parent_table}"."path"
+                )'''
+                return queryset.annotate(**{
+                    count_attr: RawSQL(sql, [], output_field=models.IntegerField())
+                })
+            elif has_generic_fk:
+                content_type = ContentType.objects.get_for_model(queryset.model)
+                sql = f'''(
+                    SELECT COUNT(DISTINCT "{related_table}"."id")
+                    FROM "{related_table}"
+                    INNER JOIN "{parent_table}" AS subtree
+                      ON "{related_table}"."scope_id" = subtree."id"
+                    WHERE "{related_table}"."scope_type_id" = %s
+                      AND subtree."path" <@ "{parent_table}"."path"
+                )'''
+                return queryset.annotate(**{
+                    count_attr: RawSQL(sql, [content_type.pk], output_field=models.IntegerField())
+                })
+            else:
+                rel_field_col = f'{rel_field}_id'
+                sql = f'''(
+                    SELECT COUNT(DISTINCT "{related_table}"."id")
+                    FROM "{related_table}"
+                    INNER JOIN "{parent_table}" AS subtree
+                      ON "{related_table}"."{rel_field_col}" = subtree."id"
+                    WHERE subtree."path" <@ "{parent_table}"."path"
+                )'''
+                return queryset.annotate(**{
+                    count_attr: RawSQL(sql, [], output_field=models.IntegerField())
+                })
+
+        # Non-cumulative: direct count.
+        if is_many_to_many:
+            return queryset.annotate(**{count_attr: Count(rel_field, distinct=True)})
+        if has_generic_fk:
+            content_type = ContentType.objects.get_for_model(queryset.model)
+            subquery = model.objects.filter(
+                scope_type=content_type, scope_id=OuterRef('pk')
+            ).values('scope_id').annotate(c=Count('id')).values('c')
+            return queryset.annotate(**{
+                count_attr: Subquery(subquery, output_field=models.IntegerField())
+            })
+        return queryset.annotate(**{count_attr: Count(rel_field, distinct=True)})
+
+
+class LtreeManager(models.Manager.from_queryset(LtreeQuerySet)):
+    """Drop-in replacement for django-mptt's TreeManager."""
+
+    def get_queryset(self):
+        return super().get_queryset().order_by('path')
+
+
+#
+# Abstract model
+#
+
+class LtreeModel(models.Model):
+    """
+    Abstract base for hierarchical models backed by PostgreSQL ltree.
+
+    Subclasses must declare a `parent = models.ForeignKey('self', ...)`. The
+    `path` column is maintained by per-table triggers installed via
+    InstallLtreeTriggers; do not write to it from Python.
+    """
+    path = LtreeField(editable=False, null=False, blank=True, default='')
+
+    objects = LtreeManager()
+
+    class Meta:
+        abstract = True
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._loaded_parent_id = self.parent_id
+
+    @classmethod
+    def from_db(cls, db, field_names, values):
+        instance = super().from_db(db, field_names, values)
+        instance._loaded_parent_id = instance.parent_id
+        return instance
+
+    def save(self, *args, **kwargs):
+        """
+        Triggers compute `path` server-side. After insert or after a parent
+        change, refresh just the path column so the in-memory instance stays
+        consistent with the database.
+        """
+        is_insert = self._state.adding
+        parent_changed = (not is_insert) and self.parent_id != self._loaded_parent_id
+        super().save(*args, **kwargs)
+        if is_insert or parent_changed:
+            self.path = type(self).objects.values_list('path', flat=True).get(pk=self.pk)
+        self._loaded_parent_id = self.parent_id
+
+    # -- MPTT-compatible API ------------------------------------------------
+
+    @property
+    def level(self):
+        """Zero-based depth (root = 0). Mirrors django-mptt's `level`."""
+        if not self.path:
+            return 0
+        return str(self.path).count('.')
+
+    def get_level(self):
+        return self.level
+
+    @property
+    def tree_id(self):
+        """Integer PK of the root, mirroring django-mptt's `tree_id`."""
+        if not self.path:
+            return None
+        root_label = str(self.path).split('.', 1)[0]
+        try:
+            return int(root_label)
+        except (TypeError, ValueError):
+            return root_label
+
+    def is_root_node(self):
+        return self.parent_id is None
+
+    def is_leaf_node(self):
+        return not type(self).objects.filter(parent_id=self.pk).exists()
+
+    def is_child_node(self):
+        return self.parent_id is not None
+
+    def get_root(self):
+        if self.is_root_node():
+            return self
+        root_pk = int(str(self.path).split('.', 1)[0])
+        return type(self)._default_manager.get(pk=root_pk)
+
+    def get_parent(self):
+        return self.parent
+
+    def get_ancestors(self, ascending=False, include_self=False):
+        if not self.path:
+            return type(self)._default_manager.none()
+        qs = type(self)._default_manager.filter(path__ancestor=self.path)
+        if not include_self:
+            qs = qs.exclude(pk=self.pk)
+        return qs.order_by('-path' if ascending else 'path')
+
+    def get_descendants(self, include_self=False):
+        if not self.path:
+            return type(self)._default_manager.none()
+        qs = type(self)._default_manager.filter(path__descendant=self.path)
+        if not include_self:
+            qs = qs.exclude(pk=self.pk)
+        return qs.order_by('path')
+
+    def get_descendant_count(self):
+        if not self.path:
+            return 0
+        return type(self)._default_manager.filter(
+            path__descendant=self.path
+        ).exclude(pk=self.pk).count()
+
+    def get_children(self):
+        return type(self)._default_manager.filter(parent_id=self.pk)
+
+    def get_family(self):
+        """Ancestors + self + descendants, in path order."""
+        if not self.path:
+            return type(self)._default_manager.none()
+        return type(self)._default_manager.filter(
+            Q(path__ancestor=self.path) | Q(path__descendant=self.path)
+        ).distinct().order_by('path')
+
+    def get_siblings(self, include_self=False):
+        qs = type(self)._default_manager.filter(parent_id=self.parent_id)
+        if not include_self:
+            qs = qs.exclude(pk=self.pk)
+        return qs
+
+    def move_to(self, target, position='last-child'):
+        """
+        Re-parent this node under `target`. Triggers handle path recomputation
+        for self and all descendants. `position` is accepted for django-mptt
+        compatibility; first-/last-child both mean "child of target" and
+        left/right mean "sibling of target".
+        """
+        if position in ('first-child', 'last-child', None):
+            new_parent = target
+        elif position in ('left', 'right'):
+            new_parent = target.parent if target else None
+        else:
+            raise ValueError(f"Unsupported move_to position: {position!r}")
+        self.parent = new_parent
+        self.save()
+
+    def insert_at(self, target, position='last-child', save=False):
+        """Set parent (optionally save). Mirrors django-mptt's insert_at."""
+        if position in ('first-child', 'last-child', None):
+            self.parent = target
+        elif position in ('left', 'right'):
+            self.parent = target.parent if target else None
+        else:
+            raise ValueError(f"Unsupported insert_at position: {position!r}")
+        if save:
+            self.save()
+
+
+#
+# Migration operation
+#
+
+_COMPUTE_PATH_FN = '''
+CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
+DECLARE parent_path ltree;
+BEGIN
+    IF NEW.parent_id IS NOT NULL THEN
+        EXECUTE format('SELECT path FROM %%I WHERE id = $1', TG_TABLE_NAME)
+            INTO parent_path USING NEW.parent_id;
+        NEW.path := parent_path || NEW.id::text::ltree;
+    ELSE
+        NEW.path := NEW.id::text::ltree;
+    END IF;
+    RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+'''
+
+_CASCADE_PATH_FN = '''
+CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
+BEGIN
+    EXECUTE format(
+        'UPDATE %%I SET path = $1 || subpath(path, nlevel($2))'
+        ' WHERE path <@ $2 AND id != $3',
+        TG_TABLE_NAME
+    ) USING NEW.path, OLD.path, NEW.id;
+    RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+'''
+
+_BEFORE_TRIGGER = '''
+CREATE TRIGGER "{table}_ltree_compute_path"
+    BEFORE INSERT OR UPDATE OF parent_id ON "{table}"
+    FOR EACH ROW EXECUTE FUNCTION "{table}_ltree_compute_path_fn"();
+'''
+
+_AFTER_TRIGGER = '''
+CREATE TRIGGER "{table}_ltree_cascade_path"
+    AFTER UPDATE OF parent_id, path ON "{table}"
+    FOR EACH ROW WHEN (OLD.path IS DISTINCT FROM NEW.path)
+    EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
+'''
+
+
+class InstallLtreeTriggers(migrations.operations.base.Operation):
+    """
+    Install per-table ltree path-maintenance triggers.
+
+    Two row-level triggers are installed on each target table:
+
+        BEFORE INSERT OR UPDATE OF parent_id -> compute NEW.path
+        AFTER UPDATE OF path WHEN distinct   -> cascade path change to descendants
+
+    The trigger function bodies use TG_TABLE_NAME so the SQL is table-agnostic,
+    but each table gets its own pair of CREATE FUNCTION statements to keep
+    pg_proc entries identifiable and to avoid surprising cross-table coupling.
+    """
+    reversible = True
+
+    def __init__(self, table_name):
+        self.table_name = table_name
+
+    def state_forwards(self, app_label, state):
+        pass
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        schema_editor.execute(_COMPUTE_PATH_FN.format(table=self.table_name))
+        schema_editor.execute(_CASCADE_PATH_FN.format(table=self.table_name))
+        schema_editor.execute(_BEFORE_TRIGGER.format(table=self.table_name))
+        schema_editor.execute(_AFTER_TRIGGER.format(table=self.table_name))
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        t = self.table_name
+        schema_editor.execute(f'DROP TRIGGER IF EXISTS "{t}_ltree_cascade_path" ON "{t}";')
+        schema_editor.execute(f'DROP TRIGGER IF EXISTS "{t}_ltree_compute_path" ON "{t}";')
+        schema_editor.execute(f'DROP FUNCTION IF EXISTS "{t}_ltree_cascade_path_fn"();')
+        schema_editor.execute(f'DROP FUNCTION IF EXISTS "{t}_ltree_compute_path_fn"();')
+
+    def describe(self):
+        return f"Install ltree path triggers on {self.table_name}"

+ 0 - 1
netbox/netbox/settings.py

@@ -454,7 +454,6 @@ INSTALLED_APPS = [
     'django_tables2',
     'django_prometheus',
     'strawberry_django',
-    'mptt',
     'rest_framework',
     'social_django',
     'sorl.thumbnail',

+ 7 - 2
netbox/netbox/tables/columns.py

@@ -44,6 +44,7 @@ __all__ = (
     'TagColumn',
     'TemplateColumn',
     'ToggleColumn',
+    'TreeColumn',
     'UtilizationColumn',
 )
 
@@ -599,9 +600,9 @@ class CustomLinkColumn(tables.Column):
         return None
 
 
-class MPTTColumn(tables.TemplateColumn):
+class TreeColumn(tables.TemplateColumn):
     """
-    Display a nested hierarchy for MPTT-enabled models.
+    Display a nested hierarchy for tree-enabled models (Region, Location, etc.).
     """
     template_code = """
         {% load helpers %}
@@ -623,6 +624,10 @@ class MPTTColumn(tables.TemplateColumn):
         return value
 
 
+# Deprecated alias for plugin compatibility; use TreeColumn going forward.
+MPTTColumn = TreeColumn
+
+
 class UtilizationColumn(tables.TemplateColumn):
     """
     Display a colored utilization bar graph.

+ 1 - 1
netbox/netbox/tables/tables.py

@@ -340,7 +340,7 @@ class NestedGroupModelTable(NetBoxTable):
         linkify=True,
         verbose_name=_('Owner'),
     )
-    name = columns.MPTTColumn(
+    name = columns.TreeColumn(
         verbose_name=_('Name'),
         linkify=True
     )

+ 4 - 21
netbox/netbox/views/generic/bulk_views.py

@@ -15,7 +15,6 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
-from mptt.models import MPTTModel
 
 from core.exceptions import JobFailed
 from core.models import ObjectType
@@ -557,12 +556,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
         } if prefetch_ids else {}
 
-        # For MPTT models, delay tree updates until all saves are complete
-        if issubclass(self.queryset.model, MPTTModel):
-            with self.queryset.model.objects.delay_mptt_updates():
-                saved_objects = self._process_import_records(form, request, records, prefetched_objects)
-        else:
-            saved_objects = self._process_import_records(form, request, records, prefetched_objects)
+        saved_objects = self._process_import_records(form, request, records, prefetched_objects)
 
         return saved_objects
 
@@ -756,10 +750,6 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             if is_background_request(request):
                 request.job.logger.info(f"Updated {obj}")
 
-        # Rebuild the tree for MPTT models
-        if issubclass(self.queryset.model, MPTTModel):
-            self.queryset.model.objects.rebuild()
-
         return updated_objects
 
     #
@@ -935,16 +925,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                         renamed_pks = self._rename_objects(form, selected_objects)
 
                         if '_apply' in request.POST:
-                            # For MPTT models, delay tree updates until all saves are complete
-                            if issubclass(self.queryset.model, MPTTModel):
-                                with self.queryset.model.objects.delay_mptt_updates():
-                                    for obj in selected_objects:
-                                        setattr(obj, self.field_name, obj.new_name)
-                                        obj.save()
-                            else:
-                                for obj in selected_objects:
-                                    setattr(obj, self.field_name, obj.new_name)
-                                    obj.save()
+                            for obj in selected_objects:
+                                setattr(obj, self.field_name, obj.new_name)
+                                obj.save()
 
                             # Enforce constrained permissions
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):

+ 0 - 1
netbox/templates/dcim/module.html

@@ -2,7 +2,6 @@
 {% load helpers %}
 {% load plugins %}
 {% load i18n %}
-{% load mptt %}
 
 {% block breadcrumbs %}
   {{ block.super }}

+ 3 - 3
netbox/tenancy/api/views.py

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 
-from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from tenancy import filtersets
 from tenancy.models import *
 
@@ -19,7 +19,7 @@ class TenancyRootView(APIRootView):
 # Tenants
 #
 
-class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class TenantGroupViewSet(NetBoxModelViewSet):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         Tenant,
@@ -41,7 +41,7 @@ class TenantViewSet(NetBoxModelViewSet):
 # Contacts
 #
 
-class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class ContactGroupViewSet(NetBoxModelViewSet):
     queryset = ContactGroup.objects.annotate_contacts()
     serializer_class = serializers.ContactGroupSerializer
     filterset_class = filtersets.ContactGroupFilterSet

+ 2 - 2
netbox/tenancy/graphql/types.py

@@ -88,7 +88,7 @@ class TenantType(ContactsMixin, PrimaryObjectType):
 
 @strawberry_django.type(
     models.TenantGroup,
-    fields='__all__',
+    exclude=['path'],
     filters=TenantGroupFilter,
     pagination=True
 )
@@ -125,7 +125,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
 
 @strawberry_django.type(
     models.ContactGroup,
-    fields='__all__',
+    exclude=['path'],
     filters=ContactGroupFilter,
     pagination=True
 )

+ 1 - 2
netbox/tenancy/migrations/0001_squashed_0012.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 from django.db import migrations, models
 
@@ -45,7 +44,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,

+ 1 - 2
netbox/tenancy/migrations/0002_squashed_0011.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 from django.db import migrations, models
 
@@ -69,7 +68,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,

+ 17 - 0
netbox/tenancy/migrations/0025_enable_ltree_extension.py

@@ -0,0 +1,17 @@
+from django.contrib.postgres.operations import CreateExtension
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    """
+    Enable the PostgreSQL ltree extension. Idempotent across apps; only one
+    CreateExtension('ltree') needs to succeed during a single migrate run.
+    """
+
+    dependencies = [
+        ('tenancy', '0024_default_ordering_indexes'),
+    ]
+
+    operations = [
+        CreateExtension('ltree'),
+    ]

+ 74 - 0
netbox/tenancy/migrations/0026_ltree_paths.py

@@ -0,0 +1,74 @@
+"""Replace django-mptt with PostgreSQL ltree for tenancy's hierarchical models."""
+from django.contrib.postgres.indexes import GistIndex
+from django.db import migrations
+
+import netbox.models.ltree
+from netbox.models.ltree import InstallLtreeTriggers
+
+MODELS = ('tenantgroup', 'contactgroup')
+TABLES = ('tenancy_tenantgroup', 'tenancy_contactgroup')
+LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
+
+
+def _populate_paths_sql():
+    blocks = []
+    for table in TABLES:
+        blocks.append(f"""
+WITH RECURSIVE t(id, parent_id, path) AS (
+    SELECT id, parent_id, id::text::ltree FROM "{table}" WHERE parent_id IS NULL
+    UNION ALL
+    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    FROM "{table}" r JOIN t ON r.parent_id = t.id
+)
+UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
+""")
+    return '\n'.join(blocks)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0025_enable_ltree_extension'),
+    ]
+
+    operations = [
+        *[
+            migrations.AddField(
+                model_name=m,
+                name='path',
+                field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
+            )
+            for m in MODELS
+        ],
+
+        *[InstallLtreeTriggers(t) for t in TABLES],
+
+        migrations.RunSQL(_populate_paths_sql(), reverse_sql=migrations.RunSQL.noop),
+
+        *[
+            migrations.AlterField(
+                model_name=m,
+                name='path',
+                field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
+            )
+            for m in MODELS
+        ],
+
+        # Drop legacy (tree_id, lft) indexes added in 0023_add_mptt_tree_indexes,
+        # then drop the legacy MPTT columns.
+        migrations.RemoveIndex(model_name='contactgroup', name='tenancy_contactgroup_tree_d2ce'),
+        migrations.RemoveIndex(model_name='tenantgroup', name='tenancy_tenantgroup_tree_ifebc'),
+        *[
+            migrations.RemoveField(model_name=m, name=f)
+            for m in MODELS for f in LEGACY_FIELDS
+        ],
+
+        migrations.AddIndex(
+            model_name='tenantgroup',
+            index=GistIndex(fields=['path'], name='tenancy_tenantgroup_path_gist'),
+        ),
+        migrations.AddIndex(
+            model_name='contactgroup',
+            index=GistIndex(fields=['path'], name='tenancy_contactgroup_path_gist'),
+        ),
+    ]

+ 10 - 10
netbox/tenancy/models/contacts.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models.expressions import RawSQL
@@ -7,8 +8,8 @@ from django.utils.translation import gettext_lazy as _
 
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, has_feature
+from netbox.models.ltree import LtreeManager
 from tenancy.choices import *
-from utilities.mptt import TreeManager
 
 __all__ = (
     'Contact',
@@ -18,23 +19,22 @@ __all__ = (
 )
 
 
-class ContactGroupManager(TreeManager):
+class ContactGroupManager(LtreeManager):
 
     def annotate_contacts(self):
         """
         Annotate the total number of Contacts belonging to each ContactGroup.
 
-        This returns both direct children and children of child groups. Raw SQL is used here to avoid double-counting
-        contacts which are assigned to multiple child groups of the parent.
+        Counts contacts assigned to the group itself or any descendant group (via the
+        ltree `<@` operator on the path column). DISTINCT avoids double-counting
+        contacts which are assigned to multiple groups in the subtree.
         """
         return self.annotate(
             contact_count=RawSQL(
                 "SELECT COUNT(DISTINCT m2m.contact_id)"
                 " FROM tenancy_contact_groups m2m"
                 " INNER JOIN tenancy_contactgroup cg ON m2m.contactgroup_id = cg.id"
-                " WHERE cg.tree_id = tenancy_contactgroup.tree_id"
-                " AND cg.lft >= tenancy_contactgroup.lft"
-                " AND cg.lft <= tenancy_contactgroup.rght",
+                " WHERE cg.path <@ tenancy_contactgroup.path",
                 ()
             )
         )
@@ -48,9 +48,9 @@ class ContactGroup(NestedGroupModel):
 
     class Meta:
         ordering = ['name']
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='tenancy_contactgroup_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),

+ 4 - 3
netbox/tenancy/models/tenants.py

@@ -1,3 +1,4 @@
+from django.contrib.postgres.indexes import GistIndex
 from django.db import models
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
@@ -29,9 +30,9 @@ class TenantGroup(NestedGroupModel):
 
     class Meta:
         ordering = ['name']
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='tenancy_tenantgroup_path_gist'),
+        )
         verbose_name = _('tenant group')
         verbose_name_plural = _('tenant groups')
 

+ 0 - 24
netbox/utilities/mptt.py

@@ -1,24 +0,0 @@
-from django.db.models import Manager
-from mptt.managers import TreeManager as TreeManager_
-from mptt.querysets import TreeQuerySet as TreeQuerySet_
-
-from .querysets import RestrictedQuerySet
-
-__all__ = (
-    'TreeManager',
-    'TreeQuerySet',
-)
-
-
-class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet):
-    """
-    Mate django-mptt's TreeQuerySet with our RestrictedQuerySet for permissions enforcement.
-    """
-    pass
-
-
-class TreeManager(Manager.from_queryset(TreeQuerySet), TreeManager_):
-    """
-    Extend django-mptt's TreeManager to incorporate RestrictedQuerySet().
-    """
-    pass

+ 4 - 3
netbox/utilities/query.py

@@ -1,7 +1,7 @@
 from django.db.models import Count, OuterRef, QuerySet, Subquery
 from django.db.models.functions import Coalesce
 
-from utilities.mptt import TreeManager
+from netbox.models.ltree import LtreeManager
 
 __all__ = (
     'count_related',
@@ -64,8 +64,9 @@ def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
     Reapply model-level ordering in case it has been lost through .annotate().
     https://code.djangoproject.com/ticket/32811
     """
-    # MPTT-based models are exempt from this; use caution when annotating querysets of these models
-    if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
+    # Hierarchical (ltree) models are exempt; their default ordering by `path` must not be
+    # clobbered by .annotate(). Use caution when annotating querysets of these models.
+    if any(isinstance(manager, LtreeManager) for manager in queryset.model._meta.local_managers):
         return queryset
     if queryset.ordered:
         return queryset

+ 0 - 21
netbox/utilities/templatetags/mptt.py

@@ -1,21 +0,0 @@
-from django import template
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
-
-register = template.Library()
-
-
-@register.simple_tag()
-def nested_tree(obj):
-    """
-    Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
-    """
-    if not obj:
-        return mark_safe('&mdash;')
-
-    nodes = obj.get_ancestors(include_self=True)
-    return mark_safe(
-        ' / '.join(
-            f'<a href="{node.get_absolute_url()}">{escape(node)}</a>' for node in nodes
-        )
-    )

+ 4 - 7
netbox/utilities/testing/filtersets.py

@@ -6,10 +6,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
 from django.utils.module_loading import import_string
-from mptt.models import MPTTModel
 from taggit.managers import TaggableManager
 
 from extras.filters import TagFilter
+from netbox.models.ltree import LtreeModel
 from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 
 __all__ = (
@@ -20,10 +20,7 @@ __all__ = (
 EXEMPT_MODEL_FIELDS = (
     'comments',
     'custom_field_data',
-    'level',    # MPTT
-    'lft',      # MPTT
-    'rght',     # MPTT
-    'tree_id',  # MPTT
+    'path',  # ltree, trigger-maintained
 )
 
 
@@ -59,8 +56,8 @@ class BaseFilterSetTests:
             if field.related_model is ContentType:
                 return [(None, None)]
 
-            # ForeignKey to an MPTT-enabled model
-            if issubclass(field.related_model, MPTTModel) and field.model is not field.related_model:
+            # ForeignKey to an ltree-backed hierarchical model
+            if issubclass(field.related_model, LtreeModel) and field.model is not field.related_model:
                 return [(f'{filter_name}_id', TreeNodeMultipleChoiceFilter)]
 
             return [(f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter)]

+ 4 - 3
netbox/utilities/tests/test_filters.py

@@ -2,7 +2,6 @@ import django_filters
 from django.conf import settings
 from django.db import models
 from django.test import TestCase
-from mptt.fields import TreeForeignKey
 from taggit.managers import TaggableManager
 
 from dcim.choices import *
@@ -113,9 +112,11 @@ class DummyModel(models.Model):
     integerfield = models.IntegerField()
     macaddressfield = MACAddressField()
     timefield = models.TimeField()
-    treeforeignkeyfield = TreeForeignKey(
+    treeforeignkeyfield = models.ForeignKey(
         to='self',
-        on_delete=models.CASCADE
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
     )
 
     tags = TaggableManager(through=TaggedItem)

+ 174 - 0
netbox/utilities/tests/test_ltree.py

@@ -0,0 +1,174 @@
+"""Tests for the ltree-based hierarchical model infrastructure."""
+from django.db import connection
+from django.test import TestCase
+
+from dcim.models import Region, Site
+
+
+class LtreeTriggerTests(TestCase):
+    """Verify per-row PostgreSQL triggers maintain `path` correctly."""
+
+    def test_insert_root_path(self):
+        r = Region.objects.create(name='Root', slug='root')
+        self.assertEqual(r.path, str(r.pk))
+
+    def test_insert_child_path(self):
+        r = Region.objects.create(name='Root', slug='root')
+        c = Region.objects.create(parent=r, name='Child', slug='child')
+        self.assertEqual(c.path, f'{r.pk}.{c.pk}')
+
+    def test_grandchild_path(self):
+        r = Region.objects.create(name='R', slug='r')
+        c = Region.objects.create(parent=r, name='C', slug='c')
+        g = Region.objects.create(parent=c, name='G', slug='g')
+        self.assertEqual(g.path, f'{r.pk}.{c.pk}.{g.pk}')
+
+    def test_move_cascades_to_descendants(self):
+        r = Region.objects.create(name='R', slug='r')
+        c = Region.objects.create(parent=r, name='C', slug='c')
+        g = Region.objects.create(parent=c, name='G', slug='g')
+        c.parent = None
+        c.save()
+        c.refresh_from_db()
+        g.refresh_from_db()
+        self.assertEqual(c.path, str(c.pk))
+        self.assertEqual(g.path, f'{c.pk}.{g.pk}')
+
+    def test_bulk_create_populates_paths(self):
+        """BEFORE INSERT trigger fires on bulk_create, populating path."""
+        root = Region.objects.create(name='R', slug='r-bulk')
+        children = Region.objects.bulk_create([
+            Region(parent=root, name=f'C{i}', slug=f'c{i}-bulk') for i in range(3)
+        ])
+        for child in children:
+            child.refresh_from_db()
+            self.assertEqual(child.path, f'{root.pk}.{child.pk}')
+
+    def test_queryset_update_with_parent_id_cascades(self):
+        """Raw .update() that changes parent_id still fires triggers."""
+        r1 = Region.objects.create(name='R1', slug='r1-up')
+        r2 = Region.objects.create(name='R2', slug='r2-up')
+        c = Region.objects.create(parent=r1, name='C', slug='c-up')
+        g = Region.objects.create(parent=c, name='G', slug='g-up')
+
+        Region.objects.filter(pk=c.pk).update(parent=r2)
+        c.refresh_from_db()
+        g.refresh_from_db()
+        self.assertEqual(c.path, f'{r2.pk}.{c.pk}')
+        self.assertEqual(g.path, f'{r2.pk}.{c.pk}.{g.pk}')
+
+    def test_gist_index_exists(self):
+        """Every ltree-backed table has a GiST index on path."""
+        expected = {
+            'dcim_region_path_gist',
+            'dcim_sitegroup_path_gist',
+            'dcim_location_path_gist',
+            'dcim_devicerole_path_gist',
+            'dcim_platform_path_gist',
+            'dcim_inventoryitem_path_gist',
+            'dcim_inv_item_tmpl_path_gist',
+            'dcim_modulebay_path_gist',
+            'tenancy_tenantgroup_path_gist',
+            'tenancy_contactgroup_path_gist',
+            'wireless_lan_grp_path_gist',
+        }
+        with connection.cursor() as cursor:
+            cursor.execute("""
+                SELECT indexname FROM pg_indexes
+                WHERE indexname = ANY(%s) AND indexdef LIKE '%%USING gist%%'
+            """, [list(expected)])
+            found = {row[0] for row in cursor.fetchall()}
+        self.assertSetEqual(found, expected)
+
+
+class LtreeAPIParityTests(TestCase):
+    """Verify the MPTTModel-compatible API surface."""
+
+    @classmethod
+    def setUpTestData(cls):
+        # Build:  root -> mid -> leaf
+        #              -> leaf2 (sibling of mid's child)
+        cls.root = Region.objects.create(name='Root', slug='root-api')
+        cls.mid = Region.objects.create(parent=cls.root, name='Mid', slug='mid-api')
+        cls.leaf = Region.objects.create(parent=cls.mid, name='Leaf', slug='leaf-api')
+        cls.leaf2 = Region.objects.create(parent=cls.mid, name='Leaf2', slug='leaf2-api')
+
+    def test_level(self):
+        self.assertEqual(self.root.level, 0)
+        self.assertEqual(self.mid.level, 1)
+        self.assertEqual(self.leaf.level, 2)
+        self.assertEqual(self.leaf.get_level(), 2)
+
+    def test_is_root_leaf_child(self):
+        self.assertTrue(self.root.is_root_node())
+        self.assertFalse(self.root.is_leaf_node())
+        self.assertFalse(self.root.is_child_node())
+        self.assertFalse(self.leaf.is_root_node())
+        self.assertTrue(self.leaf.is_leaf_node())
+        self.assertTrue(self.leaf.is_child_node())
+
+    def test_get_root(self):
+        self.assertEqual(self.leaf.get_root(), self.root)
+        self.assertEqual(self.root.get_root(), self.root)
+
+    def test_get_ancestors(self):
+        ancestors = list(self.leaf.get_ancestors().values_list('name', flat=True))
+        self.assertEqual(ancestors, ['Root', 'Mid'])
+        with_self = list(self.leaf.get_ancestors(include_self=True).values_list('name', flat=True))
+        self.assertEqual(with_self, ['Root', 'Mid', 'Leaf'])
+
+    def test_get_descendants(self):
+        descendants = sorted(self.root.get_descendants().values_list('name', flat=True))
+        self.assertEqual(descendants, ['Leaf', 'Leaf2', 'Mid'])
+        self.assertEqual(self.root.get_descendant_count(), 3)
+
+    def test_get_children(self):
+        children = sorted(self.root.get_children().values_list('name', flat=True))
+        self.assertEqual(children, ['Mid'])
+
+    def test_get_siblings(self):
+        siblings = list(self.leaf.get_siblings().values_list('name', flat=True))
+        self.assertEqual(siblings, ['Leaf2'])
+
+    def test_get_family(self):
+        family = sorted(self.mid.get_family().values_list('name', flat=True))
+        self.assertEqual(family, ['Leaf', 'Leaf2', 'Mid', 'Root'])
+
+    def test_move_to(self):
+        new_root = Region.objects.create(name='New', slug='new-api')
+        self.leaf.move_to(new_root)
+        self.leaf.refresh_from_db()
+        self.assertEqual(self.leaf.parent, new_root)
+        self.assertEqual(self.leaf.path, f'{new_root.pk}.{self.leaf.pk}')
+
+
+class CycleValidationTests(TestCase):
+    """clean() must refuse to assign a descendant as parent."""
+
+    def test_cycle_raises(self):
+        from django.core.exceptions import ValidationError
+        a = Region.objects.create(name='A', slug='a-cyc')
+        b = Region.objects.create(parent=a, name='B', slug='b-cyc')
+        Region.objects.create(parent=b, name='C', slug='c-cyc')
+        a.parent = b
+        with self.assertRaises(ValidationError):
+            a.full_clean()
+
+
+class AddRelatedCountTests(TestCase):
+    """add_related_count must cumulate across subtrees via path <@."""
+
+    def test_cumulative_fk_count(self):
+        root = Region.objects.create(name='R', slug='r-arc')
+        child = Region.objects.create(parent=root, name='C', slug='c-arc')
+        Site.objects.create(name='S1', slug='s1-arc', region=child)
+        Site.objects.create(name='S2', slug='s2-arc', region=root)
+
+        qs = Region.objects.add_related_count(
+            Region.objects.filter(slug__endswith='-arc'),
+            Site, 'region', 'site_count', cumulative=True,
+        )
+        counts = {r.name: r.site_count for r in qs}
+        # root sees both sites (direct + via child)
+        self.assertEqual(counts['R'], 2)
+        self.assertEqual(counts['C'], 1)

+ 2 - 2
netbox/wireless/api/views.py

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 
-from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from wireless import filtersets
 from wireless.models import *
 
@@ -15,7 +15,7 @@ class WirelessRootView(APIRootView):
         return 'Wireless'
 
 
-class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class WirelessLANGroupViewSet(NetBoxModelViewSet):
     queryset = WirelessLANGroup.objects.add_related_count(
         WirelessLANGroup.objects.all(),
         WirelessLAN,

+ 1 - 1
netbox/wireless/graphql/types.py

@@ -22,7 +22,7 @@ __all__ = (
 
 @strawberry_django.type(
     models.WirelessLANGroup,
-    fields='__all__',
+    exclude=['path'],
     filters=WirelessLANGroupFilter,
     pagination=True
 )

+ 1 - 2
netbox/wireless/migrations/0001_squashed_0008.py

@@ -1,5 +1,4 @@
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 from django.db import migrations, models
 
@@ -46,7 +45,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,

+ 17 - 0
netbox/wireless/migrations/0020_enable_ltree_extension.py

@@ -0,0 +1,17 @@
+from django.contrib.postgres.operations import CreateExtension
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    """
+    Enable the PostgreSQL ltree extension. Idempotent across apps; only one
+    CreateExtension('ltree') needs to succeed during a single migrate run.
+    """
+
+    dependencies = [
+        ('wireless', '0019_default_ordering_indexes'),
+    ]
+
+    operations = [
+        CreateExtension('ltree'),
+    ]

+ 56 - 0
netbox/wireless/migrations/0021_ltree_paths.py

@@ -0,0 +1,56 @@
+"""Replace django-mptt with PostgreSQL ltree for wireless's hierarchical models."""
+from django.contrib.postgres.indexes import GistIndex
+from django.db import migrations
+
+import netbox.models.ltree
+from netbox.models.ltree import InstallLtreeTriggers
+
+MODEL = 'wirelesslangroup'
+TABLE = 'wireless_wirelesslangroup'
+LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0020_enable_ltree_extension'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name=MODEL,
+            name='path',
+            field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
+        ),
+
+        InstallLtreeTriggers(TABLE),
+
+        migrations.RunSQL(
+            f"""
+WITH RECURSIVE t(id, parent_id, path) AS (
+    SELECT id, parent_id, id::text::ltree FROM "{TABLE}" WHERE parent_id IS NULL
+    UNION ALL
+    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    FROM "{TABLE}" r JOIN t ON r.parent_id = t.id
+)
+UPDATE "{TABLE}" SET path = t.path FROM t WHERE "{TABLE}".id = t.id;
+""",
+            reverse_sql=migrations.RunSQL.noop,
+        ),
+
+        migrations.AlterField(
+            model_name=MODEL,
+            name='path',
+            field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
+        ),
+
+        # Drop legacy (tree_id, lft) index added in 0018_add_mptt_tree_indexes,
+        # then drop the legacy MPTT columns.
+        migrations.RemoveIndex(model_name=MODEL, name='wireless_wirelesslangroup_fbcd'),
+        *[migrations.RemoveField(model_name=MODEL, name=f) for f in LEGACY_FIELDS],
+
+        migrations.AddIndex(
+            model_name=MODEL,
+            index=GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
+        ),
+    ]

+ 4 - 3
netbox/wireless/models.py

@@ -1,3 +1,4 @@
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
@@ -64,9 +65,9 @@ class WirelessLANGroup(NestedGroupModel):
 
     class Meta:
         ordering = ('name', 'pk')
-        # Empty tuple triggers Django migration detection for MPTT indexes
-        # (see #21016, django-mptt/django-mptt#682)
-        indexes = ()
+        indexes = (
+            GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
+        )
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),

+ 0 - 1
requirements.txt

@@ -5,7 +5,6 @@ django-debug-toolbar==6.3.0
 django-filter==25.2
 django-graphiql-debug-toolbar==0.2.0
 django-htmx==1.27.0
-django-mptt==0.18.0
 django-pglocks==1.0.4
 django-prometheus==2.4.0
 django-redis==6.0.0