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
 # https://django-htmx.readthedocs.io/en/latest/changelog.html
 django-htmx
 django-htmx
 
 
-# Modified Preorder Tree Traversal (recursive nesting of objects)
-django-mptt
-
 # Context managers for PostgreSQL advisory locks
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 django-pglocks
 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.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
 
 
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.querysets import ObjectChangeQuerySet
 from core.querysets import ObjectChangeQuerySet
@@ -164,9 +163,10 @@ class ObjectChange(models.Model):
         if issubclass(model, ChangeLoggingMixin):
         if issubclass(model, ChangeLoggingMixin):
             attrs.update({'created', 'last_updated'})
             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
         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.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 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 netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.query import count_related
 from utilities.query import count_related
@@ -95,7 +95,7 @@ class PassThroughPortMixin:
 # Regions
 # Regions
 #
 #
 
 
-class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class RegionViewSet(NetBoxModelViewSet):
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
         Site,
         Site,
@@ -111,7 +111,7 @@ class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 # Site groups
 # Site groups
 #
 #
 
 
-class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class SiteGroupViewSet(NetBoxModelViewSet):
     queryset = SiteGroup.objects.add_related_count(
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
         SiteGroup.objects.all(),
         Site,
         Site,
@@ -137,7 +137,7 @@ class SiteViewSet(NetBoxModelViewSet):
 # Locations
 # Locations
 #
 #
 
 
-class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class LocationViewSet(NetBoxModelViewSet):
     queryset = Location.objects.add_related_count(
     queryset = Location.objects.add_related_count(
         Location.objects.add_related_count(
         Location.objects.add_related_count(
             Location.objects.all(),
             Location.objects.all(),
@@ -356,7 +356,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceBayTemplateFilterSet
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 
 
-class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class InventoryItemTemplateViewSet(NetBoxModelViewSet):
     queryset = InventoryItemTemplate.objects.all()
     queryset = InventoryItemTemplate.objects.all()
     serializer_class = serializers.InventoryItemTemplateSerializer
     serializer_class = serializers.InventoryItemTemplateSerializer
     filterset_class = filtersets.InventoryItemTemplateFilterSet
     filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -388,7 +388,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class PlatformViewSet(NetBoxModelViewSet):
     queryset = Platform.objects.add_related_count(
     queryset = Platform.objects.add_related_count(
         Platform.objects.add_related_count(
         Platform.objects.add_related_count(
             Platform.objects.all(),
             Platform.objects.all(),
@@ -543,7 +543,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceBayFilterSet
     filterset_class = filtersets.DeviceBayFilterSet
 
 
 
 
-class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class InventoryItemViewSet(NetBoxModelViewSet):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
     serializer_class = serializers.InventoryItemSerializer
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet
     filterset_class = filtersets.InventoryItemFilterSet

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

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

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

@@ -1,5 +1,4 @@
 import django.db.models.deletion
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 import taggit.managers
 from django.conf import settings
 from django.conf import settings
 from django.db import migrations, models
 from django.db import migrations, models
@@ -27,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='sitegroup',
             model_name='sitegroup',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -76,7 +75,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='region',
             model_name='region',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -381,7 +380,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='location',
             model_name='location',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 on_delete=django.db.models.deletion.CASCADE,
@@ -417,7 +416,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='inventoryitem',
             model_name='inventoryitem',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 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.core.validators
 import django.db.models.deletion
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 import taggit.managers
 from django.db import migrations, models
 from django.db import migrations, models
 
 
@@ -1248,7 +1247,7 @@ class Migration(migrations.Migration):
                 ),
                 ),
                 (
                 (
                     'parent',
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         blank=True,
                         null=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
                         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 django.db.models.deletion
-import mptt.fields
 from django.db import migrations, models
 from django.db import migrations, models
 
 
 
 
@@ -44,7 +43,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='modulebay',
             model_name='modulebay',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 editable=False,
                 editable=False,
                 null=True,
                 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
 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):
 class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
         ('dcim', '0190_nested_modules'),
         ('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 = [
     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
 # Generated by Django 5.1.7 on 2025-03-25 18:06
 
 
 import django.db.models.manager
 import django.db.models.manager
-import mptt.fields
 from django.db import migrations, models
 from django.db import migrations, models
 
 
 
 
@@ -39,7 +38,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='devicerole',
             model_name='devicerole',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 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
 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):
 class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
         ('dcim', '0203_device_role_nested'),
         ('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 = [
     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 django.db.models.deletion
-import mptt.fields
 from django.db import migrations, models
 from django.db import migrations, models
 
 
 
 
@@ -14,7 +13,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='platform',
             model_name='platform',
             name='parent',
             name='parent',
-            field=mptt.fields.TreeForeignKey(
+            field=django.db.models.ForeignKey(
                 blank=True,
                 blank=True,
                 null=True,
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 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
 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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
         ('dcim', '0213_platform_parent'),
         ('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 = [
     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
 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):
 class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
         ('dcim', '0226_add_mptt_tree_indexes'),
         ('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 = [
     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.contenttypes.fields import GenericForeignKey
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -11,8 +11,8 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
+from netbox.models.ltree import LtreeManager, LtreeModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
 from wireless.choices import WirelessRoleChoices
 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.
     A template for an InventoryItem to be created for a new parent Device.
     """
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='child_items',
         related_name='child_items',
@@ -860,13 +860,15 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
         help_text=_('Manufacturer-assigned part identifier')
         help_text=_('Manufacturer-assigned part identifier')
     )
     )
 
 
-    objects = TreeManager()
     component_model = InventoryItem
     component_model = InventoryItem
 
 
+    objects = LtreeManager()
+
     class Meta:
     class Meta:
         ordering = ('device_type__id', 'parent__id', 'name')
         ordering = ('device_type__id', 'parent__id', 'name')
         indexes = (
         indexes = (
             models.Index(fields=('component_type', 'component_id')),
             models.Index(fields=('component_type', 'component_id')),
+            GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'),
         )
         )
         constraints = (
         constraints = (
             models.UniqueConstraint(
             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.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -15,9 +15,9 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models import NetBoxModel, OrganizationalModel
+from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.mixins import OwnerMixin
 from netbox.models.mixins import OwnerMixin
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -1313,11 +1313,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 # Bays
 # Bays
 #
 #
 
 
-class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
+class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
     """
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='children',
         related_name='children',
@@ -1337,14 +1337,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         default=True,
         default=True,
     )
     )
 
 
-    objects = TreeManager()
-
     clone_fields = ('device', 'enabled')
     clone_fields = ('device', 'enabled')
 
 
+    objects = LtreeManager()
+
     class Meta(ModularComponentModel.Meta):
     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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('device', 'module', 'name'),
                 fields=('device', 'module', 'name'),
@@ -1354,9 +1354,6 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         verbose_name = _('module bay')
         verbose_name = _('module bay')
         verbose_name_plural = _('module bays')
         verbose_name_plural = _('module bays')
 
 
-    class MPTTMeta:
-        order_insertion_by = ('name',)
-
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -1469,12 +1466,12 @@ class InventoryItemRole(OrganizationalModel):
         verbose_name_plural = _('inventory item roles')
         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.
     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.
     InventoryItems are used only for inventory purposes.
     """
     """
-    parent = TreeForeignKey(
+    parent = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='child_items',
         related_name='child_items',
@@ -1542,14 +1539,15 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         help_text=_('This item was automatically discovered')
         help_text=_('This item was automatically discovered')
     )
     )
 
 
-    objects = TreeManager()
-
     clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
     clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
 
 
+    objects = LtreeManager()
+
     class Meta:
     class Meta:
         ordering = ('device__id', 'parent__id', 'name')
         ordering = ('device__id', 'parent__id', 'name')
         indexes = (
         indexes = (
             models.Index(fields=('component_type', 'component_id')),
             models.Index(fields=('component_type', 'component_id')),
+            GistIndex(fields=['path'], name='dcim_inventoryitem_path_gist'),
         )
         )
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(

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

@@ -4,6 +4,7 @@ from functools import cached_property
 import yaml
 import yaml
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -412,9 +413,9 @@ class DeviceRole(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),
@@ -466,9 +467,9 @@ class Platform(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         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 = _('platform')
         verbose_name_plural = _('platforms')
         verbose_name_plural = _('platforms')
         constraints = (
         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.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 from jsonschema.exceptions import ValidationError as JSONValidationError
-from mptt.models import MPTTModel
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.utils import create_port_mappings, update_interface_bridges
 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._location = self.device.location
                 component._rack = self.device.rack
                 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']
             update_fields = ['module']
 
 
-            # we handle create and update separately - this is for update
             component_model.objects.bulk_update(update_instances, update_fields)
             component_model.objects.bulk_update(update_instances, update_fields)
-            # Emit the post_save signal for each updated object
             for component in update_instances:
             for component in update_instances:
                 post_save.send(
                 post_save.send(
                     sender=component_model,
                     sender=component_model,
@@ -385,10 +377,6 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
                     update_fields=update_fields
                     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
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
         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.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
+from django.contrib.postgres.indexes import GistIndex
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
@@ -44,9 +45,9 @@ class Region(ContactsMixin, NestedGroupModel):
     )
     )
 
 
     class Meta:
     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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),
@@ -103,9 +104,9 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     )
     )
 
 
     class Meta:
     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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),
@@ -324,9 +325,9 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),
                 fields=('site', 'parent', 'name'),

+ 7 - 21
netbox/extras/querysets.py

@@ -130,10 +130,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         if self.model._meta.model_name == 'device':
         if self.model._meta.model_name == 'device':
             base_query.add(
             base_query.add(
                 (Q(
                 (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(locations=None)),
                 Q.AND
                 Q.AND
             )
             )
@@ -142,40 +139,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
             base_query.add(Q(locations=None), Q.AND)
             base_query.add(Q(locations=None), Q.AND)
             base_query.add(Q(device_types=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(
         base_query.add(
             (Q(
             (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(regions=None)),
             Q.AND
             Q.AND
         )
         )
         base_query.add(
         base_query.add(
             (Q(
             (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(site_groups=None)),
             Q.AND
             Q.AND
         )
         )
         base_query.add(
         base_query.add(
             (Q(
             (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(roles=None)),
             Q.AND
             Q.AND
         )
         )
         base_query.add(
         base_query.add(
             (Q(
             (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(platforms=None)),
             Q.AND
             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.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import router, transaction
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
 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 mixins as drf_mixins
 from rest_framework import status
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
 
 
 from netbox.api.serializers.features import ChangeLogMessageSerializer
 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.api import get_annotations_for_serializer, get_prefetches_for_serializer
 from utilities.exceptions import AbortRequest, PreconditionFailed
 from utilities.exceptions import AbortRequest, PreconditionFailed
 from utilities.query import reapply_model_ordering
 from utilities.query import reapply_model_ordering
@@ -337,20 +335,3 @@ class NetBoxModelViewSet(
             raise PermissionDenied()
             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-vlans': 100300,
     'available-asns': 100400,
     'available-asns': 100400,
 
 
-    # MPTT locks
-    'region': 105100,
-    'sitegroup': 105200,
-    'location': 105300,
-    'tenantgroup': 105400,
-    'contactgroup': 105500,
-    'wirelesslangroup': 105600,
-    'inventoryitem': 105700,
-    'inventoryitemtemplate': 105800,
-    'platform': 105900,
-
     # Jobs
     # Jobs
     'job-schedules': 110100,
     '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:
 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:
     try:
         node = model_class.objects.get(id=filter_value.id)
         node = model_class.objects.get(id=filter_value.id)
     except model_class.DoesNotExist:
     except model_class.DoesNotExist:
         return Q(pk__in=[])
         return Q(pk__in=[])
 
 
+    if not getattr(node, 'path', None):
+        return Q(id=filter_value.id)
+
     if filter_value.match_type == TreeNodeMatch.EXACT:
     if filter_value.match_type == TreeNodeMatch.EXACT:
         return Q(id=filter_value.id)
         return Q(id=filter_value.id)
     if filter_value.match_type == TreeNodeMatch.DESCENDANTS:
     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:
     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:
     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:
     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:
     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:
     if filter_value.match_type == TreeNodeMatch.PARENT:
         return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
         return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
     return Q()
     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.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 
 
 from netbox.models.features import *
 from netbox.models.features import *
+from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.mixins import OwnerMixin
 from netbox.models.mixins import OwnerMixin
-from utilities.mptt import TreeManager
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 __all__ = (
 __all__ = (
@@ -159,16 +158,12 @@ class PrimaryModel(OwnerMixin, NetBoxModel):
         abstract = True
         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
     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',
         to='self',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='children',
         related_name='children',
@@ -194,13 +189,13 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
         blank=True
         blank=True
     )
     )
 
 
-    objects = TreeManager()
+    # Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet
+    # default manager via MRO resolution.
+    objects = LtreeManager()
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
-
-    class MPTTMeta:
-        order_insertion_by = ('name',)
+        ordering = ('path',)
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -208,7 +203,7 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
     def clean(self):
     def clean(self):
         super().clean()
         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):
         if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True):
             raise ValidationError({
             raise ValidationError({
                 "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name)
                 "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_tables2',
     'django_prometheus',
     'django_prometheus',
     'strawberry_django',
     'strawberry_django',
-    'mptt',
     'rest_framework',
     'rest_framework',
     'social_django',
     'social_django',
     'sorl.thumbnail',
     'sorl.thumbnail',

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

@@ -44,6 +44,7 @@ __all__ = (
     'TagColumn',
     'TagColumn',
     'TemplateColumn',
     'TemplateColumn',
     'ToggleColumn',
     'ToggleColumn',
+    'TreeColumn',
     'UtilizationColumn',
     'UtilizationColumn',
 )
 )
 
 
@@ -599,9 +600,9 @@ class CustomLinkColumn(tables.Column):
         return None
         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 = """
     template_code = """
         {% load helpers %}
         {% load helpers %}
@@ -623,6 +624,10 @@ class MPTTColumn(tables.TemplateColumn):
         return value
         return value
 
 
 
 
+# Deprecated alias for plugin compatibility; use TreeColumn going forward.
+MPTTColumn = TreeColumn
+
+
 class UtilizationColumn(tables.TemplateColumn):
 class UtilizationColumn(tables.TemplateColumn):
     """
     """
     Display a colored utilization bar graph.
     Display a colored utilization bar graph.

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

@@ -340,7 +340,7 @@ class NestedGroupModelTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name=_('Owner'),
         verbose_name=_('Owner'),
     )
     )
-    name = columns.MPTTColumn(
+    name = columns.TreeColumn(
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         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.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
-from mptt.models import MPTTModel
 
 
 from core.exceptions import JobFailed
 from core.exceptions import JobFailed
 from core.models import ObjectType
 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)
             for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
         } if prefetch_ids else {}
         } 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
         return saved_objects
 
 
@@ -756,10 +750,6 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             if is_background_request(request):
             if is_background_request(request):
                 request.job.logger.info(f"Updated {obj}")
                 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
         return updated_objects
 
 
     #
     #
@@ -935,16 +925,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                         renamed_pks = self._rename_objects(form, selected_objects)
                         renamed_pks = self._rename_objects(form, selected_objects)
 
 
                         if '_apply' in request.POST:
                         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
                             # Enforce constrained permissions
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
                             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 helpers %}
 {% load plugins %}
 {% load plugins %}
 {% load i18n %}
 {% load i18n %}
-{% load mptt %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}

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

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

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

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

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

@@ -1,5 +1,4 @@
 import django.db.models.deletion
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 import taggit.managers
 from django.db import migrations, models
 from django.db import migrations, models
 
 
@@ -45,7 +44,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                 (
                     'parent',
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         blank=True,
                         null=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
                         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 django.db.models.deletion
-import mptt.fields
 import taggit.managers
 import taggit.managers
 from django.db import migrations, models
 from django.db import migrations, models
 
 
@@ -69,7 +68,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                 (
                     'parent',
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         blank=True,
                         null=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
                         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.contenttypes.fields import GenericForeignKey
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.db.models.expressions import RawSQL
 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 import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, has_feature
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, has_feature
+from netbox.models.ltree import LtreeManager
 from tenancy.choices import *
 from tenancy.choices import *
-from utilities.mptt import TreeManager
 
 
 __all__ = (
 __all__ = (
     'Contact',
     'Contact',
@@ -18,23 +19,22 @@ __all__ = (
 )
 )
 
 
 
 
-class ContactGroupManager(TreeManager):
+class ContactGroupManager(LtreeManager):
 
 
     def annotate_contacts(self):
     def annotate_contacts(self):
         """
         """
         Annotate the total number of Contacts belonging to each ContactGroup.
         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(
         return self.annotate(
             contact_count=RawSQL(
             contact_count=RawSQL(
                 "SELECT COUNT(DISTINCT m2m.contact_id)"
                 "SELECT COUNT(DISTINCT m2m.contact_id)"
                 " FROM tenancy_contact_groups m2m"
                 " FROM tenancy_contact_groups m2m"
                 " INNER JOIN tenancy_contactgroup cg ON m2m.contactgroup_id = cg.id"
                 " 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:
     class Meta:
         ordering = ['name']
         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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 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 import models
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -29,9 +30,9 @@ class TenantGroup(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         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 = _('tenant group')
         verbose_name_plural = _('tenant groups')
         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 import Count, OuterRef, QuerySet, Subquery
 from django.db.models.functions import Coalesce
 from django.db.models.functions import Coalesce
 
 
-from utilities.mptt import TreeManager
+from netbox.models.ltree import LtreeManager
 
 
 __all__ = (
 __all__ = (
     'count_related',
     '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().
     Reapply model-level ordering in case it has been lost through .annotate().
     https://code.djangoproject.com/ticket/32811
     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
         return queryset
     if queryset.ordered:
     if queryset.ordered:
         return queryset
         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.contrib.contenttypes.models import ContentType
 from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
 from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
-from mptt.models import MPTTModel
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from extras.filters import TagFilter
 from extras.filters import TagFilter
+from netbox.models.ltree import LtreeModel
 from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 
 
 __all__ = (
 __all__ = (
@@ -20,10 +20,7 @@ __all__ = (
 EXEMPT_MODEL_FIELDS = (
 EXEMPT_MODEL_FIELDS = (
     'comments',
     'comments',
     'custom_field_data',
     '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:
             if field.related_model is ContentType:
                 return [(None, None)]
                 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', TreeNodeMultipleChoiceFilter)]
 
 
             return [(f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter)]
             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.conf import settings
 from django.db import models
 from django.db import models
 from django.test import TestCase
 from django.test import TestCase
-from mptt.fields import TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -113,9 +112,11 @@ class DummyModel(models.Model):
     integerfield = models.IntegerField()
     integerfield = models.IntegerField()
     macaddressfield = MACAddressField()
     macaddressfield = MACAddressField()
     timefield = models.TimeField()
     timefield = models.TimeField()
-    treeforeignkeyfield = TreeForeignKey(
+    treeforeignkeyfield = models.ForeignKey(
         to='self',
         to='self',
-        on_delete=models.CASCADE
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
     )
     )
 
 
     tags = TaggableManager(through=TaggedItem)
     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 rest_framework.routers import APIRootView
 
 
-from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from wireless import filtersets
 from wireless import filtersets
 from wireless.models import *
 from wireless.models import *
 
 
@@ -15,7 +15,7 @@ class WirelessRootView(APIRootView):
         return 'Wireless'
         return 'Wireless'
 
 
 
 
-class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+class WirelessLANGroupViewSet(NetBoxModelViewSet):
     queryset = WirelessLANGroup.objects.add_related_count(
     queryset = WirelessLANGroup.objects.add_related_count(
         WirelessLANGroup.objects.all(),
         WirelessLANGroup.objects.all(),
         WirelessLAN,
         WirelessLAN,

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

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

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

@@ -1,5 +1,4 @@
 import django.db.models.deletion
 import django.db.models.deletion
-import mptt.fields
 import taggit.managers
 import taggit.managers
 from django.db import migrations, models
 from django.db import migrations, models
 
 
@@ -46,7 +45,7 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(editable=False)),
                 ('level', models.PositiveIntegerField(editable=False)),
                 (
                 (
                     'parent',
                     'parent',
-                    mptt.fields.TreeForeignKey(
+                    django.db.models.ForeignKey(
                         blank=True,
                         blank=True,
                         null=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
                         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.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -64,9 +65,9 @@ class WirelessLANGroup(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ('name', 'pk')
         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 = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),

+ 0 - 1
requirements.txt

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