Explorar o código

#21488 - Replace MPTT wtih PostgreSQL Ltree

Arthur hai 14 horas
pai
achega
f63eb57e45

+ 5 - 0
base_requirements.txt

@@ -26,6 +26,11 @@ django-graphiql-debug-toolbar
 # https://django-htmx.readthedocs.io/en/latest/changelog.html
 django-htmx
 
+# Modified Preorder Tree Traversal (required only for historical migrations
+# that pre-date the switch to PostgreSQL ltree; runtime code uses
+# netbox.models.ltree.LtreeModel instead).
+django-mptt
+
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 django-pglocks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 23 - 10
netbox/netbox/models/ltree.py

@@ -11,9 +11,8 @@ 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, Lookup, ManyToManyField, OuterRef, Q, Subquery
+from django.db.models import Count, ForeignKey, Lookup, ManyToManyField, Q
 from django.db.models.expressions import RawSQL
 
 from utilities.querysets import RestrictedQuerySet
@@ -135,17 +134,24 @@ class LtreeQuerySet(RestrictedQuerySet):
                     count_attr: RawSQL(sql, [], output_field=models.IntegerField())
                 })
             if has_generic_fk:
-                content_type = ContentType.objects.get_for_model(queryset.model)
+                # Resolve scope_type_id via subquery so this annotation can be
+                # constructed at import time (e.g. in a view class body) even
+                # before contenttypes has been migrated.
+                ct_app = queryset.model._meta.app_label
+                ct_model = queryset.model._meta.model_name
                 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
+                    WHERE "{related_table}"."scope_type_id" = (
+                        SELECT id FROM django_content_type
+                        WHERE app_label = %s AND model = %s
+                    )
                       AND subtree."path" <@ "{parent_table}"."path"
                 )'''
                 return queryset.annotate(**{
-                    count_attr: RawSQL(sql, [content_type.pk], output_field=models.IntegerField())
+                    count_attr: RawSQL(sql, [ct_app, ct_model], output_field=models.IntegerField())
                 })
             rel_field_col = f'{rel_field}_id'
             sql = f'''(
@@ -163,12 +169,19 @@ class LtreeQuerySet(RestrictedQuerySet):
         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')
+            ct_app = queryset.model._meta.app_label
+            ct_model = queryset.model._meta.model_name
+            sql = f'''(
+                SELECT COUNT(DISTINCT "{related_table}"."id")
+                FROM "{related_table}"
+                WHERE "{related_table}"."scope_id" = "{parent_table}"."id"
+                  AND "{related_table}"."scope_type_id" = (
+                      SELECT id FROM django_content_type
+                      WHERE app_label = %s AND model = %s
+                  )
+            )'''
             return queryset.annotate(**{
-                count_attr: Subquery(subquery, output_field=models.IntegerField())
+                count_attr: RawSQL(sql, [ct_app, ct_model], output_field=models.IntegerField())
             })
         return queryset.annotate(**{count_attr: Count(rel_field, distinct=True)})
 

+ 1 - 0
netbox/netbox/settings.py

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

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

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

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

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

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

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

+ 1 - 0
requirements.txt

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