Closes #21418: Replace MPTT wtih PostgreSQL Ltree (#22296)

This commit is contained in:
Arthur Hanson 2026-06-13 16:39:05 -07:00 committed by GitHub
parent 1f0d505b91
commit 8d941047b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2985 additions and 368 deletions

View File

@ -27,6 +27,10 @@ django-graphiql-debug-toolbar
django-htmx
# Modified Preorder Tree Traversal (recursive nesting of objects)
# Retained primarily for plugin backward compatibility: the deprecated
# NestedGroupModel base remains MPTT-backed for plugins still using it. Also
# required by historical migrations that pre-date the switch to PostgreSQL ltree.
# NetBox core runtime uses netbox.models.ltree.LtreeModel instead.
django-mptt
# Context managers for PostgreSQL advisory locks

View File

@ -6,7 +6,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
@ -160,13 +159,26 @@ class ObjectChange(models.Model):
model = self.changed_object_type.model_class()
attrs = set()
# model_class() returns None when the model's app is no longer installed
# (e.g. a removed plugin); there are no fields to exclude, and the
# issubclass() checks below would raise TypeError on None.
if model is None:
return attrs
# Exclude auto-populated change tracking fields
if issubclass(model, ChangeLoggingMixin):
attrs.update({'created', 'last_updated'})
# Exclude MPTT-internal fields
# Exclude trigger-maintained ltree columns (path and the optional sort_path)
from netbox.models.ltree import LtreeModel
if issubclass(model, LtreeModel):
attrs.update({'path', 'sort_path'})
# Exclude MPTT bookkeeping columns for the deprecated MPTT-backed
# NestedGroupModel still shipped for plugin compatibility.
from mptt.models import MPTTModel
if issubclass(model, MPTTModel):
attrs.update({'level', 'lft', 'rght', 'tree_id'})
attrs.update({'lft', 'rght', 'tree_id', 'level'})
return attrs

View File

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

View File

@ -12,7 +12,8 @@ from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import (
BaseObjectType,
NestedGroupObjectType,
LtreeNodeMixin,
NestedLtreeGroupObjectType,
NetBoxObjectType,
OrganizationalObjectType,
PrimaryObjectType,
@ -322,11 +323,11 @@ class DeviceBayTemplateType(ComponentTemplateType):
@strawberry_django.type(
models.InventoryItemTemplate,
exclude=['component_type', 'component_id', 'parent'],
exclude=['component_type', 'component_id', 'parent', 'path'],
filters=InventoryItemTemplateFilter,
pagination=True
)
class InventoryItemTemplateType(ComponentTemplateType):
class InventoryItemTemplateType(LtreeNodeMixin, ComponentTemplateType):
role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
@ -350,11 +351,11 @@ class InventoryItemTemplateType(ComponentTemplateType):
@strawberry_django.type(
models.DeviceRole,
fields='__all__',
exclude=['path', 'sort_path'],
filters=DeviceRoleFilter,
pagination=True
)
class DeviceRoleType(NestedGroupObjectType):
class DeviceRoleType(NestedLtreeGroupObjectType):
parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None
children: list[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]]
color: str
@ -487,11 +488,11 @@ class InterfaceTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.InventoryItem,
exclude=['component_type', 'component_id', 'parent'],
exclude=['component_type', 'component_id', 'parent', 'path'],
filters=InventoryItemFilter,
pagination=True
)
class InventoryItemType(ComponentType):
class InventoryItemType(LtreeNodeMixin, ComponentType):
role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')] | None
@ -529,11 +530,11 @@ class InventoryItemRoleType(OrganizationalObjectType):
@strawberry_django.type(
models.Location,
# fields='__all__',
exclude=['parent'], # bug - temp
exclude=['parent', 'path', 'sort_path'], # bug - temp
filters=LocationFilter,
pagination=True
)
class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NestedGroupObjectType):
class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NestedLtreeGroupObjectType):
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
parent: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
@ -602,11 +603,11 @@ class ModuleType(PrimaryObjectType):
@strawberry_django.type(
models.ModuleBay,
# fields='__all__',
exclude=['parent'],
exclude=['parent', 'path', 'sort_path'],
filters=ModuleBayFilter,
pagination=True
)
class ModuleBayType(ModularComponentType):
class ModuleBayType(LtreeNodeMixin, ModularComponentType):
installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
@ -667,11 +668,11 @@ class ModuleTypeType(PrimaryObjectType):
@strawberry_django.type(
models.Platform,
fields='__all__',
exclude=['path', 'sort_path'],
filters=PlatformFilter,
pagination=True
)
class PlatformType(NestedGroupObjectType):
class PlatformType(NestedLtreeGroupObjectType):
parent: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None
children: list[Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')]]
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
@ -875,11 +876,11 @@ class RearPortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.Region,
exclude=['parent'],
exclude=['parent', 'path', 'sort_path'],
filters=RegionFilter,
pagination=True
)
class RegionType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
class RegionType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
@ -952,11 +953,11 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
@strawberry_django.type(
models.SiteGroup,
exclude=['parent'], # bug - temp
exclude=['parent', 'path', 'sort_path'], # bug - temp
filters=SiteGroupFilter,
pagination=True
)
class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]

View File

@ -0,0 +1,283 @@
"""
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. Enables the PostgreSQL ltree extension (idempotent).
2. Adds a nullable `path` LTreeField. For models that previously had
`MPTTMeta.order_insertion_by = ('name',)` Region, SiteGroup, Location,
DeviceRole, Platform, ModuleBay also adds a `sort_path` text column.
3. Installs per-table BEFORE/AFTER triggers. For models with sort_path, the
trigger maintains both columns.
4. Populates path (and sort_path where applicable) for existing rows via a
single recursive CTE per table.
5. Tightens path to NOT NULL.
6. Drops the legacy MPTT columns (lft, rght, tree_id, level).
7. Adds a GiST index on path (descendant/ancestor lookups via `<@` / `@>`).
For sort_path models, also adds a btree index for ORDER BY listing.
!!! OPERATOR WARNING !!!
Step 4 runs ONE recursive-CTE UPDATE per table over every row, taking a
row-exclusive lock on the entire table for the duration of the statement.
On large deployments particularly dcim_inventoryitem, which can contain
millions of rows this can block writes for minutes. Plan a maintenance
window and budget accordingly. The other tables (region, sitegroup,
location, devicerole, platform, modulebay, inventoryitemtemplate) are
typically far smaller.
Notes:
- The reverse migration is lossy: it re-adds the MPTT columns (lft/rght/tree_id/
level) empty and does NOT rebuild the tree. Forward migration is the supported
direction.
"""
import django.db.models.deletion
from django.contrib.postgres.indexes import GistIndex
from django.contrib.postgres.operations import CreateExtension
from django.db import migrations, models
import netbox.models.ltree
from utilities.ltree import InstallLtreeTriggers
from utilities.mptt_to_ltree import assert_paths_populated_sql, populate_paths_sql
# All models getting an ltree `path` column.
ALL_MODELS = (
'region', 'sitegroup', 'location', 'devicerole', 'platform',
'inventoryitem', 'inventoryitemtemplate', 'modulebay',
)
ALL_TABLES = (
'dcim_region',
'dcim_sitegroup',
'dcim_location',
'dcim_devicerole',
'dcim_platform',
'dcim_inventoryitem',
'dcim_inventoryitemtemplate',
'dcim_modulebay',
)
# Subset that previously declared `MPTTMeta.order_insertion_by = ('name',)` and
# therefore needs a `sort_path` text column maintained alongside `path`.
SORT_MODELS = ('region', 'sitegroup', 'location', 'devicerole', 'platform', 'modulebay')
SORT_TABLES = (
'dcim_region',
'dcim_sitegroup',
'dcim_location',
'dcim_devicerole',
'dcim_platform',
'dcim_modulebay',
)
LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0237_module_remove_local_context_data'),
]
operations = [
# Enable the ltree extension first so the migration fails fast if it is missing.
CreateExtension('ltree'),
# Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey
# (no-op at the SQL level; reconciles migration state with model definitions).
migrations.AlterField(
model_name='devicerole', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.devicerole'),
),
migrations.AlterField(
model_name='inventoryitem', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='child_items', to='dcim.inventoryitem'),
),
migrations.AlterField(
model_name='inventoryitemtemplate', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='child_items', to='dcim.inventoryitemtemplate'),
),
migrations.AlterField(
model_name='location', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.location'),
),
migrations.AlterField(
model_name='modulebay', name='parent',
field=models.ForeignKey(blank=True, editable=False, null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.modulebay'),
),
migrations.AlterField(
model_name='platform', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.platform'),
),
migrations.AlterField(
model_name='region', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.region'),
),
migrations.AlterField(
model_name='sitegroup', name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='dcim.sitegroup'),
),
# 2. Add nullable path column on all tree models.
*[
migrations.AddField(
model_name=m, name='path',
field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
)
for m in ALL_MODELS
],
# 2b. Add sort_path column (with default '') on the 6 models with order_insertion_by.
# ModuleBay's `name` uses the natural_sort collation, so its sort_path must
# match it (Slot 0..Slot 13, not lexicographic Slot 0, 1, 10, 2...). The other
# five use a plain-collation `name`, so their sort_path stays plain.
*[
migrations.AddField(
model_name=m, name='sort_path',
field=models.TextField(blank=True, default='', editable=False),
)
for m in SORT_MODELS if m != 'modulebay'
],
migrations.AddField(
model_name='modulebay', name='sort_path',
field=models.TextField(blank=True, default='', editable=False, db_collation='natural_sort'),
),
# 3. Install path-maintenance triggers. Models with sort_path get triggers
# that maintain both columns; the other two get path-only triggers.
*[InstallLtreeTriggers(t, name_column='name') for t in SORT_TABLES],
InstallLtreeTriggers('dcim_inventoryitem'),
InstallLtreeTriggers('dcim_inventoryitemtemplate'),
# 4. Populate existing rows via per-table recursive CTE (sort_path only
# for the models that carry it).
migrations.RunSQL(
'\n'.join(populate_paths_sql(t, sort_path=t in SORT_TABLES) for t in ALL_TABLES),
reverse_sql=migrations.RunSQL.noop,
),
# 4b. Fail fast (with a useful message) if any row still has NULL path —
# otherwise the AlterField below aborts opaquely inside ALTER COLUMN.
migrations.RunSQL(
'\n'.join(assert_paths_populated_sql(t) for t in ALL_TABLES),
reverse_sql=migrations.RunSQL.noop,
),
# 5. Tighten path 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 ALL_MODELS
],
# 6. Update Meta.ordering on the SORT_MODELS to reflect sort_path-based ordering.
migrations.AlterModelOptions(
name='devicerole', options={'ordering': ('sort_path',)},
),
# InventoryItem has no sort_path; its global list is flat + alphabetical
# (the per-device tab renders the hierarchy via get_children().order_by('path')).
migrations.AlterModelOptions(
name='inventoryitem', options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='location', options={'ordering': ('site', 'sort_path')},
),
migrations.AlterModelOptions(
name='modulebay', options={'ordering': ('sort_path', 'pk')},
),
migrations.AlterModelOptions(
name='platform', options={'ordering': ('sort_path',)},
),
migrations.AlterModelOptions(
name='region', options={'ordering': ('sort_path',)},
),
migrations.AlterModelOptions(
name='sitegroup', options={'ordering': ('sort_path',)},
),
# 7. 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 ALL_MODELS for f in LEGACY_FIELDS
],
# 8. Add GiST indexes on path (descendant/ancestor containment).
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'),
),
# 9. Add btree indexes on sort_path (tree-flatten ORDER BY listing).
migrations.AddIndex(
model_name='region',
index=models.Index(fields=['sort_path'], name='dcim_region_sort_path_idx'),
),
migrations.AddIndex(
model_name='sitegroup',
index=models.Index(fields=['sort_path'], name='dcim_sitegroup_sort_path_idx'),
),
migrations.AddIndex(
model_name='location',
index=models.Index(fields=['sort_path'], name='dcim_location_sort_path_idx'),
),
migrations.AddIndex(
model_name='devicerole',
index=models.Index(fields=['sort_path'], name='dcim_devicerole_sort_path_idx'),
),
migrations.AddIndex(
model_name='platform',
index=models.Index(fields=['sort_path'], name='dcim_platform_sort_path_idx'),
),
migrations.AddIndex(
model_name='modulebay',
index=models.Index(fields=['sort_path'], name='dcim_modulebay_sort_path_idx'),
),
]

View File

@ -1,9 +1,9 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
@ -11,8 +11,8 @@ from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.models import ChangeLoggedModel
from netbox.models.ltree import LtreeManager, LtreeModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.tracking import TrackingModelMixin
from wireless.choices import WirelessRoleChoices
@ -752,11 +752,18 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name_plural = _('module bay templates')
def instantiate(self, **kwargs):
module = kwargs.get('module')
return self.component_model(
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
position=self.resolve_position(kwargs.get('module'), kwargs.get('device')),
name=self.resolve_name(module, kwargs.get('device')),
label=self.resolve_label(module, kwargs.get('device')),
position=self.resolve_position(module, kwargs.get('device')),
enabled=self.enabled,
# A module bay created for an installed module nests under that module's
# bay. bulk_create() bypasses ModuleBay.save() (which would otherwise set
# this), so the parent must be assigned here for the path trigger to nest
# it correctly. Device-level bays are instantiated without a module and
# remain roots (parent=None).
parent=module.module_bay if module else None,
**kwargs
)
instantiate.do_not_call_in_templates = True
@ -812,11 +819,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
}
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
class InventoryItemTemplate(LtreeModel, ComponentTemplateModel):
"""
A template for an InventoryItem to be created for a new parent Device.
"""
parent = TreeForeignKey(
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='child_items',
@ -860,13 +867,14 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
help_text=_('Manufacturer-assigned part identifier')
)
objects = TreeManager()
objects = LtreeManager()
component_model = InventoryItem
class Meta:
ordering = ('device_type__id', 'parent__id', 'name')
indexes = (
models.Index(fields=('component_type', 'component_id')),
GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'),
)
constraints = (
models.UniqueConstraint(

View File

@ -2,11 +2,11 @@ from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
@ -15,9 +15,9 @@ from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from netbox.choices import ColorChoices
from netbox.models import NetBoxModel, OrganizationalModel
from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
from netbox.models.mixins import OwnerMixin
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
@ -1313,34 +1313,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
# Bays
#
class ModuleBayManager(TreeManager):
"""
Order ModuleBays by the natural-sort name of each tree's root, then by MPTT
left value within the tree. Combined with the root-insert bypass in
ModuleBay.save(), this lets us keep MPTTMeta.order_insertion_by for cheap
intra-tree sibling placement while skipping the global tree_id renumbering
it would otherwise perform on every root insert.
"""
def get_queryset(self, *args, **kwargs):
# Use the raw manager to avoid recursing through this get_queryset() when
# building the annotation subquery.
root_name = self.model._objects_raw.filter(
tree_id=models.OuterRef('tree_id'),
level=0,
).values('name')[:1]
return super().get_queryset(*args, **kwargs).annotate(
_root_name=models.Subquery(
root_name,
output_field=models.CharField(db_collation='natural_sort'),
)
).order_by('_root_name', 'lft')
class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
"""
An empty space within a Device which can house a child device
"""
parent = TreeForeignKey(
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
@ -1359,18 +1336,31 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
verbose_name=_('enabled'),
default=True,
)
objects = ModuleBayManager()
# Plain TreeManager used by ModuleBayManager to build the _root_name subquery
# without recursing through our annotated get_queryset().
_objects_raw = TreeManager()
# sort_path inherits `name`'s natural_sort collation automatically (LtreeModelBase),
# so ORDER BY sort_path sorts siblings naturally (Slot 0..Slot 13) — as MPTT's
# order_insertion_by=('name',) did — rather than lexicographically.
sort_path = SortPathField(
editable=False,
blank=True,
default='',
)
clone_fields = ('device', 'enabled')
objects = LtreeManager()
class Meta(ModularComponentModel.Meta):
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
# Order by sort_path alone (not device-first), reproducing the MPTT
# ModuleBayManager's ('_root_name', 'lft'): sort_path begins with the tree's
# root-bay name (natural_sort collation), so the global list groups by
# root-bay name across devices, descendants following their root. `pk`
# gives a deterministic tie-break among same-named roots on different devices
# (MPTT's lft=1 left this order arbitrary).
ordering = ('sort_path', 'pk')
indexes = (
GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
models.Index(fields=['sort_path'], name='dcim_modulebay_sort_path_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('device', 'module', 'name'),
@ -1380,12 +1370,6 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
verbose_name = _('module bay')
verbose_name_plural = _('module bays')
class MPTTMeta:
# Used for placing siblings within a single tree at insert time. Costs
# are bounded to that tree's rows. Cross-tree (root) insertion is
# handled in save() to avoid the O(N) tree_id shift this would trigger.
order_insertion_by = ('name',)
def clean(self):
super().clean()
@ -1405,38 +1389,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
self.parent = self.module.module_bay
else:
self.parent = None
opts = self._mptt_meta
# For new root nodes, allocate the next tree_id and pre-set MPTT fields
# so super().save() skips MPTT's order_insertion_by-driven insertion
# path. That path would otherwise UPDATE every later tree_id on each
# root insert (NB-2800). Children still go through MPTT, which keeps
# siblings in name order via the same order_insertion_by setting.
if self._state.adding and self.parent_id is None and not self.lft and not self.rght:
max_tree_id = ModuleBay._objects_raw.aggregate(
models.Max('tree_id')
)['tree_id__max'] or 0
self.tree_id = max_tree_id + 1
self.lft = 1
self.rght = 2
self.level = 0
elif (
not self._state.adding
and self.parent_id is None
and self._mptt_cached_fields.get(opts.parent_attr) is None
):
# Existing root being updated. Spoof the cached order_insertion_by
# values so MPTT's same_order check passes and it skips its reorder
# path, which would UPDATE every later tree_id on each root rename.
# ModuleBayManager._root_name recovers display order at query time,
# so the tree_id reshuffling would be wasted work. Child renames
# and root<->child transitions intentionally fall through to MPTT.
for field_name in opts.order_insertion_by:
field_name = field_name.lstrip('-')
self._mptt_cached_fields[field_name] = opts.get_raw_field_value(
self, field_name
)
super().save(*args, **kwargs)
def _parent_creates_cycle(self):
# A ModuleBay's parent is system-derived from its module (see save()), not
# user-assigned, and module/bay recursion is validated in clean(); skip the
# generic ltree cycle guard.
return False
@property
def _occupied(self):
"""
@ -1528,12 +1488,12 @@ class InventoryItemRole(OrganizationalModel):
verbose_name_plural = _('inventory item roles')
class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
class InventoryItem(LtreeModel, ComponentModel, TrackingModelMixin):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
"""
parent = TreeForeignKey(
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='child_items',
@ -1601,14 +1561,19 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
help_text=_('This item was automatically discovered')
)
objects = TreeManager()
clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
objects = LtreeManager()
class Meta:
ordering = ('device__id', 'parent__id', 'name')
# Global list is flat + alphabetical by name (natural_sort collation). The
# per-device Inventory tab renders the hierarchy instead — DeviceInventoryView
# .get_children() orders that by `path`. `pk` is a deterministic tie-break for
# same-named items on different devices.
ordering = ('name', 'pk')
indexes = (
models.Index(fields=('component_type', 'component_id')),
GistIndex(fields=['path'], name='dcim_inventoryitem_path_gist'),
)
constraints = (
models.UniqueConstraint(
@ -1622,12 +1587,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
def clean(self):
super().clean()
# An InventoryItem cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": _("Cannot assign self as parent.")
})
# Validation for moving InventoryItems
if not self._state.adding:
# Cannot move an InventoryItem to another device if it has a parent

View File

@ -4,6 +4,7 @@ from functools import cached_property
import yaml
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
@ -23,7 +24,7 @@ from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models import NestedLtreeGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from utilities.exceptions import AbortRequest
@ -385,7 +386,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
# Devices
#
class DeviceRole(NestedGroupModel):
class DeviceRole(NestedLtreeGroupModel):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
@ -411,10 +412,11 @@ class DeviceRole(NestedGroupModel):
clone_fields = ('parent', 'description')
class Meta:
ordering = ('name',)
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='dcim_devicerole_path_gist'),
models.Index(fields=['sort_path'], name='dcim_devicerole_sort_path_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@ -441,7 +443,7 @@ class DeviceRole(NestedGroupModel):
verbose_name_plural = _('device roles')
class Platform(NestedGroupModel):
class Platform(NestedLtreeGroupModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
Platform may optionally be associated with a particular Manufacturer.
@ -465,10 +467,11 @@ class Platform(NestedGroupModel):
clone_fields = ('parent', 'description')
class Meta:
ordering = ('name',)
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='dcim_platform_path_gist'),
models.Index(fields=['sort_path'], name='dcim_platform_sort_path_idx'),
)
verbose_name = _('platform')
verbose_name_plural = _('platforms')
constraints = (

View File

@ -5,7 +5,6 @@ from django.db import models
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from mptt.models import MPTTModel
from dcim.choices import *
from dcim.utils import create_port_mappings, update_interface_bridges
@ -386,29 +385,32 @@ class Module(TrackingModelMixin, PrimaryModel):
component._location = self.device.location
component._rack = self.device.rack
# we handle create and update separately - this is for create
if not issubclass(component_model, MPTTModel):
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object
for component in create_instances:
post_save.send(
sender=component_model,
instance=component,
created=True,
raw=False,
using='default',
update_fields=None
)
else:
# MPTT models must be saved individually to maintain tree structure
for instance in create_instances:
instance.save()
# Bulk-create new instances. ModuleBay is ltree-backed: its parent is set
# in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
# and the BEFORE INSERT trigger derives path/sort_path from parent_id 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']
# ModuleBay.parent is derived from .module in ModuleBay.save(), and the
# path/sort_path trigger only fires on parent_id/name changes. A bare
# bulk_update(['module']) bypasses both, leaving the adopted bay rooted
# at its pre-adoption location. Set parent in lockstep so the BEFORE
# trigger recomputes path/sort_path.
if component_model is ModuleBay:
for instance in update_instances:
instance.parent = self.module_bay
update_fields = ['module', 'parent']
# we handle create and update separately - this is for update
component_model.objects.bulk_update(update_instances, update_fields)
# Emit the post_save signal for each updated object
for component in update_instances:
post_save.send(
sender=component_model,
@ -419,10 +421,6 @@ class Module(TrackingModelMixin, PrimaryModel):
update_fields=update_fields
)
# Rebuild MPTT tree if needed (bulk_update bypasses model save)
if issubclass(component_model, MPTTModel) and update_instances:
component_model.objects.rebuild()
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)

View File

@ -1,6 +1,7 @@
import decimal
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@ -9,7 +10,7 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models import NestedLtreeGroupModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
__all__ = (
@ -24,7 +25,7 @@ __all__ = (
# Regions
#
class Region(ContactsMixin, NestedGroupModel):
class Region(ContactsMixin, NestedLtreeGroupModel):
"""
A region represents a geographic collection of sites. For example, you might create regions representing countries,
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
@ -44,9 +45,11 @@ class Region(ContactsMixin, NestedGroupModel):
)
class Meta:
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='dcim_region_path_gist'),
models.Index(fields=['sort_path'], name='dcim_region_sort_path_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@ -83,7 +86,7 @@ class Region(ContactsMixin, NestedGroupModel):
# Site groups
#
class SiteGroup(ContactsMixin, NestedGroupModel):
class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
"""
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
@ -103,9 +106,11 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
)
class Meta:
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='dcim_sitegroup_path_gist'),
models.Index(fields=['sort_path'], name='dcim_sitegroup_sort_path_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
@ -273,7 +278,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
# Locations
#
class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
class Location(ContactsMixin, ImageAttachmentsMixin, NestedLtreeGroupModel):
"""
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
site, or a room within a building, for example.
@ -323,10 +328,16 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
)
class Meta:
ordering = ['site', 'name']
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
# Group by site, then tree-flatten within each site. This mirrors the prior
# MPTT behavior (Meta.ordering = ['site', 'name']) while upgrading the
# within-site ordering to sort_path, so descendants follow their parent in
# name order. sort_path is unique within a site (root names are unique per
# site, child names unique per parent), so no further tie-break is needed.
ordering = ('site', 'sort_path')
indexes = (
GistIndex(fields=['path'], name='dcim_location_path_gist'),
models.Index(fields=['sort_path'], name='dcim_location_sort_path_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('site', 'parent', 'name'),

View File

@ -1057,18 +1057,17 @@ class ModuleBayTestCase(TestCase):
self.assertEqual(names, ['Bay 1', 'Bay 1.1', 'Bay 1.2', 'Bay 1.3'])
@tag('regression') # #22146
def test_root_module_bay_rename_preserves_tree_ids(self):
def test_root_module_bay_rename_preserves_paths(self):
"""
Renaming a root module bay must not renumber any other root tree's
tree_id. The renamed bay's own tree_id is also expected to remain
stable, but the load-bearing assertion is that the *other* bays are
not shifted.
Renaming a root module bay must not rewrite any tree's path. Renaming
touches only sort_path (the display-ordering column), so every bay's
path including the renamed bay's own — must be unchanged afterward.
"""
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
site = Site.objects.first()
device = Device.objects.create(
name='Rename TreeID Device',
name='Rename Path Device',
device_type=device_type,
role=device_role,
site=site,
@ -1076,8 +1075,8 @@ class ModuleBayTestCase(TestCase):
for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'):
ModuleBay.objects.create(device=device, name=name)
tree_ids_before = {
bay.name: bay.tree_id
paths_before = {
bay.pk: str(bay.path)
for bay in ModuleBay.objects.filter(device=device)
}
@ -1085,18 +1084,16 @@ class ModuleBayTestCase(TestCase):
bay.name = 'Bay 99'
bay.save()
tree_ids_after = {
bay.name: bay.tree_id
paths_after = {
bay.pk: str(bay.path)
for bay in ModuleBay.objects.filter(device=device)
}
for name in ('Bay 1', 'Bay 3', 'Bay 4'):
self.assertEqual(tree_ids_after[name], tree_ids_before[name])
self.assertEqual(tree_ids_after['Bay 99'], tree_ids_before['Bay 2'])
self.assertEqual(paths_after, paths_before)
@tag('regression') # #22146
def test_root_module_bay_rename_updates_display_order(self):
"""
Even though renaming a root module bay does not renumber tree_ids,
Even though renaming a root module bay does not rewrite its path,
the manager's _root_name annotation must reflect the new name so the
display ordering is correct.
"""
@ -1186,7 +1183,9 @@ class ModuleBayTestCase(TestCase):
movable_bay.refresh_from_db()
host_bay.refresh_from_db()
self.assertEqual(movable_bay.parent_id, host_bay.pk)
self.assertEqual(movable_bay.tree_id, host_bay.tree_id)
# The trigger cascade must have re-rooted the moved bay into host_bay's
# tree: its path is now a strict descendant of host_bay's path.
self.assertTrue(str(movable_bay.path).startswith(f'{host_bay.path}.'))
def test_single_module_token(self):
device_type = DeviceType.objects.first()
@ -1265,6 +1264,38 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
@tag('regression') # #21418
def test_module_install_nests_module_bay_parent(self):
"""
A module bay instantiated when a module is installed must be nested under the
installing module's bay. bulk_create() bypasses ModuleBay.save(), so the parent
is assigned in ModuleBayTemplate.instantiate(); without it the bay would be left
a root with a top-level ltree path.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Chassis with Bay', slug='chassis-with-bay'
)
ModuleBayTemplate.objects.create(device_type=device_type, name='Bay A')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module with Sub-bay')
ModuleBayTemplate.objects.create(module_type=module_type, name='Sub-bay 1')
device = Device.objects.create(
name='Nested Bay Parent Device', device_type=device_type, role=device_role, site=site
)
parent_bay = device.modulebays.get(name='Bay A')
module = Module.objects.create(device=device, module_bay=parent_bay, module_type=module_type)
nested_bay = module.modulebays.get(name='Sub-bay 1')
self.assertEqual(nested_bay.parent, parent_bay)
# The ltree path/level must reflect the nesting, not a root placement.
self.assertEqual(nested_bay.level, parent_bay.level + 1)
self.assertTrue(str(nested_bay.path).startswith(f'{parent_bay.path}.'))
@tag('regression') # #20467
def test_nested_module_bay_position_resolution(self):
"""Test that {module} in a module bay template's position field is resolved when the module is installed."""
@ -2529,3 +2560,65 @@ class PowerPortDrawTestCase(TestCase):
self.assertEqual(legs_by_name['A']['maximum'], 200)
self.assertEqual(legs_by_name['B']['allocated'], 0)
self.assertEqual(legs_by_name['C']['allocated'], 0)
class InventoryItemCycleTestCase(TestCase):
"""
InventoryItem (ltree-backed, not the nested-group base) must reject assigning
self or a descendant as parent behavior django-mptt previously enforced via
InvalidMove on save().
"""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='inv-site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='inv-mfr-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='inv-dt-1'
)
role = DeviceRole.objects.create(name='Role 1', slug='inv-role-1')
cls.device = Device.objects.create(
name='Device 1', device_type=device_type, role=role, site=site
)
def test_cannot_assign_descendant_as_parent(self):
a = InventoryItem.objects.create(device=self.device, name='A')
b = InventoryItem.objects.create(device=self.device, name='B', parent=a)
c = InventoryItem.objects.create(device=self.device, name='C', parent=b)
a.parent = c
with self.assertRaises(ValidationError):
a.full_clean()
# The save()-level guard also rejects the cycle when clean() is bypassed.
with self.assertRaises(ValidationError):
a.save()
def test_cannot_assign_self_as_parent(self):
a = InventoryItem.objects.create(device=self.device, name='A')
a.parent = a
with self.assertRaises(ValidationError):
a.full_clean()
class InventoryItemTemplateCycleTestCase(TestCase):
"""InventoryItemTemplate must likewise reject self/descendant as parent."""
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='iit-mfr-1')
cls.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='iit-dt-1'
)
def test_cannot_assign_descendant_as_parent(self):
a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A')
b = InventoryItemTemplate.objects.create(device_type=self.device_type, name='B', parent=a)
a.parent = b
with self.assertRaises(ValidationError):
a.full_clean()
with self.assertRaises(ValidationError):
a.save()
def test_cannot_assign_self_as_parent(self):
a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A')
a.parent = a
with self.assertRaises(ValidationError):
a.full_clean()

View File

@ -2849,6 +2849,14 @@ class DeviceInventoryView(DeviceComponentsView):
hide_if_empty=True
)
def get_children(self, request, parent):
# DeviceInventoryItemTable indents rows by record.level; under MPTT,
# TreeManager forced tree-flatten (tree_id, lft) ordering so descendants
# were contiguous with their parent. InventoryItem.Meta.ordering is a
# flat sort suited to the global list; for the indented device tab,
# order by ltree path so the rendered hierarchy is correct.
return super().get_children(request, parent).order_by('path')
@register_model_view(Device, 'configcontext', path='config-context')
class DeviceConfigContextView(ObjectConfigContextView):

View File

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

View File

@ -1,17 +1,16 @@
import logging
import warnings
from functools import cached_property
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock
from rest_framework import mixins as drf_mixins
from rest_framework import status
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest, PreconditionFailed
from utilities.query import reapply_model_ordering
@ -19,6 +18,7 @@ from utilities.query import reapply_model_ordering
from . import mixins
__all__ = (
'MPTTLockedMixin',
'NetBoxModelViewSet',
'NetBoxReadOnlyModelViewSet',
)
@ -337,20 +337,25 @@ class NetBoxModelViewSet(
raise PermissionDenied()
# TODO: Remove this in NetBox v5.0
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
Deprecated no-op mixin retained for backward compatibility.
Historically this acquired a pglock around create/update/destroy to serialize
concurrent writes to MPTT-based tree models. NetBox no longer uses MPTT: tree
integrity is now maintained by the PostgreSQL ltree triggers (see
`utilities.ltree`), which take per-tree advisory locks at the database level.
This mixin is therefore now a transparent pass-through and may be removed in a
future release. Plugins should stop inheriting from it.
"""
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)
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
warnings.warn(
"MPTTLockedMixin is deprecated and no longer does anything; tree write "
"concurrency is now handled by ltree database triggers. Remove it from "
f"{cls.__name__}.",
DeprecationWarning,
stacklevel=2,
)

View File

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

View File

@ -126,7 +126,7 @@ class JSONFilter:
@strawberry.enum
class TreeNodeMatch(Enum):
EXACT = 'exact' # Just the node itself
DESCENDANTS = 'descendants' # Node and all descendants
DESCENDANTS = 'descendants' # All descendants, excluding the node itself
SELF_AND_DESCENDANTS = 'self_and_descendants' # Node and all descendants
CHILDREN = 'children' # Just immediate children
SIBLINGS = 'siblings' # Nodes with same parent
@ -159,37 +159,39 @@ class TreeNodeFilter:
# Generate base Q filter for the related model without prefix
q_filter = generate_tree_node_q_filter(related_model, self)
# Handle different relationship types
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
if isinstance(model_field, ForeignKey):
return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
if isinstance(model_field, ManyToOneRel):
# Handle different relationship types. All variants resolve the related
# rows against the q_filter (which may be a compound Q for DESCENDANTS,
# ANCESTORS, SIBLINGS, SELF_AND_DESCENDANTS) and join via __in. Destructuring
# q_filter.children into kwargs would crash on compound match types.
if isinstance(model_field, (ManyToManyField, ManyToManyRel, ForeignKey, ManyToOneRel)):
return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
def generate_tree_node_q_filter(model_class, filter_value: TreeNodeFilter) -> Q:
"""
Generate appropriate Q filter for MPTT tree filtering based on match type
Generate Q filter for ltree-backed hierarchical models based on match type.
"""
try:
node = model_class.objects.get(id=filter_value.id)
except model_class.DoesNotExist:
return Q(pk__in=[])
if not getattr(node, 'path', None):
return Q(id=filter_value.id)
if filter_value.match_type == TreeNodeMatch.EXACT:
return Q(id=filter_value.id)
if filter_value.match_type == TreeNodeMatch.DESCENDANTS:
return Q(tree_id=node.tree_id, lft__gt=node.lft, rght__lt=node.rght)
return Q(path__descendant=node.path) & ~Q(id=node.id)
if filter_value.match_type == TreeNodeMatch.SELF_AND_DESCENDANTS:
return Q(tree_id=node.tree_id, lft__gte=node.lft, rght__lte=node.rght)
return Q(path__descendant_or_equal=node.path)
if filter_value.match_type == TreeNodeMatch.CHILDREN:
return Q(tree_id=node.tree_id, level=node.level + 1, lft__gt=node.lft, rght__lt=node.rght)
return Q(parent_id=node.id)
if filter_value.match_type == TreeNodeMatch.SIBLINGS:
return Q(tree_id=node.tree_id, level=node.level, parent=node.parent) & ~Q(id=node.id)
return Q(parent_id=node.parent_id) & ~Q(id=node.id)
if filter_value.match_type == TreeNodeMatch.ANCESTORS:
return Q(tree_id=node.tree_id, lft__lt=node.lft, rght__gt=node.rght)
return Q(path__ancestor=node.path) & ~Q(id=node.id)
if filter_value.match_type == TreeNodeMatch.PARENT:
return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
return Q()

View File

@ -1,6 +1,7 @@
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from django.db.models import ExpressionWrapper, F, Func, IntegerField, Value
from strawberry.types import Info
from core.graphql.mixins import ChangelogMixin
@ -11,7 +12,9 @@ from users.graphql.mixins import OwnerMixin
__all__ = (
'BaseObjectType',
'ContentTypeType',
'LtreeNodeMixin',
'NestedGroupObjectType',
'NestedLtreeGroupObjectType',
'NetBoxObjectType',
'ObjectType',
'OrganizationalObjectType',
@ -83,6 +86,33 @@ class OrganizationalObjectType(
pass
@strawberry.type
class LtreeNodeMixin:
"""
Exposes the ltree-backed tree depth as a `level` field, preserving the `level`
field MPTT-based types previously surfaced automatically as a real column.
The depth is computed in the database as `nlevel(path) - 1` (root = 0) and
annotated onto the queryset. We prefer the annotation over the `path` column,
which is excluded from the schema. When a resolution path does not apply the
annotation (e.g. a nested relation), `ltree_level` is absent; fall back to the
loaded `path` string (the same depth the LtreeModel.level property computes)
so the field never raises AttributeError.
"""
@strawberry_django.field(annotate={
'ltree_level': ExpressionWrapper(
Func(F('path'), function='nlevel', output_field=IntegerField()) - Value(1),
output_field=IntegerField(),
)
})
def level(self) -> int:
ltree_level = getattr(self, 'ltree_level', None)
if ltree_level is not None:
return ltree_level
path = getattr(self, 'path', '') or ''
return str(path).count('.')
class NestedGroupObjectType(
ChangelogMixin,
CustomFieldsMixin,
@ -92,7 +122,25 @@ class NestedGroupObjectType(
BaseObjectType
):
"""
Base GraphQL type for models which inherit from NestedGroupModel.
Base GraphQL type for the deprecated MPTT-backed NestedGroupModel, kept for
plugin compatibility. MPTT exposes `level` as a real column, so no annotation
mixin is needed. New code should use NestedLtreeGroupObjectType.
"""
pass
class NestedLtreeGroupObjectType(
LtreeNodeMixin,
ChangelogMixin,
CustomFieldsMixin,
JournalEntriesMixin,
TagsMixin,
OwnerMixin,
BaseObjectType
):
"""
Base GraphQL type for models which inherit from NestedLtreeGroupModel.
Adds a `level` field annotated via `nlevel(path)`.
"""
pass

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from netbox.models.features import *
from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
from netbox.models.mixins import OwnerMixin
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
@ -16,6 +17,8 @@ __all__ = (
'AdminModel',
'ChangeLoggedModel',
'NestedGroupModel',
'NestedGroupModelMixin',
'NestedLtreeGroupModel',
'NetBoxModel',
'OrganizationalModel',
'PrimaryModel',
@ -159,23 +162,11 @@ class PrimaryModel(OwnerMixin, NetBoxModel):
abstract = True
class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
class NestedGroupModelMixin(OwnerMixin, NetBoxModel):
"""
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.
Shared field set and behavior for hierarchical group models. Concrete bases supply the
tree backend (MPTT or ltree) and the corresponding `parent` ForeignKey / manager.
"""
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
@ -194,6 +185,29 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
blank=True
)
class Meta:
abstract = True
def __str__(self):
return self.name
class NestedGroupModel(NestedGroupModelMixin, MPTTModel):
"""
Deprecated MPTT-backed nested group base, retained for backwards compatibility with plugins.
New code (in NetBox core and in plugins) should use `NestedLtreeGroupModel` instead. This
class will be removed in a future release once the deprecation period has elapsed.
"""
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
objects = TreeManager()
class Meta:
@ -202,19 +216,52 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
class MPTTMeta:
order_insertion_by = ('name',)
def __str__(self):
return self.name
def clean(self):
super().clean()
# An MPTT model cannot be its own parent
# A nested group cannot be its own parent or a descendant of itself. The ltree
# base (NestedLtreeGroupModel) enforces this via LtreeModel.clean(); this MPTT
# variant keeps the original get_descendants()-based check.
if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True):
raise ValidationError({
"parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name)
"parent": _("Cannot assign self or a descendant as parent.")
})
class NestedLtreeGroupModel(NestedGroupModelMixin, LtreeModel):
"""
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using PostgreSQL ltree. Within each parent, each child instance must have a unique name.
`sort_path` is a trigger-maintained text column holding a chr(9)-separated chain of ancestor
names; ordering by it yields tree-flatten output with siblings in name (collation) order.
Inserts, reparents, AND renames all update `sort_path` (a rename cascades to descendants), so
list ordering reflects renames immediately unlike django-mptt's `order_insertion_by`, which
left descendants stale until a manual rebuild.
"""
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
sort_path = SortPathField(
editable=False,
blank=True,
default='',
)
# Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet
# default manager via MRO resolution.
objects = LtreeManager()
class Meta:
abstract = True
ordering = ('sort_path',)
class OrganizationalModel(OwnerMixin, NetBoxModel):
"""
Organizational models are those which are used solely to categorize and qualify other objects, and do not convey

View File

@ -407,13 +407,13 @@ class ContactsMixin(models.Model):
"""
from tenancy.models import ContactAssignment
from . import NestedGroupModel
from . import NestedGroupModel, NestedLtreeGroupModel
filter = Q(
object_type=ObjectType.objects.get_for_model(self),
object_id__in=(
self.get_ancestors(include_self=True)
if (isinstance(self, NestedGroupModel) and inherited)
if (isinstance(self, (NestedGroupModel, NestedLtreeGroupModel)) and inherited)
else [self.pk]
),
)

View File

@ -0,0 +1,56 @@
from django.db.models import Lookup
__all__ = (
'Ancestor',
'AncestorOrEqual',
'Descendant',
'DescendantOrEqual',
)
class Ancestor(Lookup):
"""
`path` is a strict ancestor of the queried path: path @> rhs AND path <> rhs.
Use `ancestor_or_equal` for the inclusive form (`@>`).
"""
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} AND {lhs} <> {rhs})', lhs_params + rhs_params + lhs_params + rhs_params
class AncestorOrEqual(Lookup):
"""`path` is an ancestor of (or equal to) the queried path: path @> rhs"""
lookup_name = 'ancestor_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
class Descendant(Lookup):
"""
`path` is a strict descendant of the queried path: path <@ rhs AND path <> rhs.
Use `descendant_or_equal` for the inclusive form (`<@`).
"""
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} AND {lhs} <> {rhs})', lhs_params + rhs_params + lhs_params + rhs_params
class DescendantOrEqual(Lookup):
"""`path` is a descendant of (or equal to) the queried path: path <@ rhs"""
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

View File

@ -0,0 +1,536 @@
"""
Ltree-based hierarchical model support - a replacement for django-mptt backed by
a PostgreSQL ltree column.
LtreeModel covers the subset of django-mptt's MPTTModel API that NetBox actually
uses. It is deliberately NOT a full reimplementation of MPTT's surface — methods
NetBox does not rely on (e.g. get_leafnodes(), get_next_sibling(),
get_previous_sibling()) are intentionally omitted.
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.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import IntegrityError, OperationalError, connection, models
from django.db.models import ForeignKey, ManyToManyField
from django.db.models.expressions import RawSQL
from django.utils.translation import gettext_lazy as _
# Import lookup classes by their fully-qualified path rather than `from . import
# lookups`: this module is imported by netbox/models/__init__.py before that package
# finishes initializing, so a package-relative import would fail the attribute lookup
# on the partially-initialized `netbox.models` package (circular import).
from netbox.models.lookups import Ancestor, AncestorOrEqual, Descendant, DescendantOrEqual
from utilities.querysets import RestrictedQuerySet
__all__ = (
'LtreeField',
'LtreeManager',
'LtreeModel',
'LtreeQuerySet',
'SortPathField',
)
#
# 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"
# `path` is computed by a BEFORE INSERT trigger, so its final value isn't known
# until the row is written. Marking the field db_returning lets the
# INSERT ... RETURNING clause fetch the trigger-computed value in the same
# round-trip (PostgreSQL evaluates RETURNING after BEFORE triggers fire),
# avoiding a follow-up SELECT in LtreeModel.save(). Mirrors AutoFieldMixin.
db_returning = True
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(Ancestor)
LtreeField.register_lookup(AncestorOrEqual)
LtreeField.register_lookup(Descendant)
LtreeField.register_lookup(DescendantOrEqual)
class SortPathField(models.TextField):
"""
Text column holding the chr(9)-separated chain of ancestor names that drives
tree-flatten ordering. Like `path`, its value is maintained by triggers, so it
is marked db_returning to be populated via INSERT ... RETURNING without an
extra SELECT. It deconstructs as a plain TextField so existing migrations
(which created the column as TextField) require no schema change.
"""
db_returning = True
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
return name, 'django.db.models.TextField', args, kwargs
#
# QuerySet / Manager
#
class LtreeQuerySet(RestrictedQuerySet):
"""QuerySet for ltree-based hierarchies, layered on RestrictedQuerySet."""
def bulk_create(self, objs, *args, **kwargs):
"""
Same as the standard `bulk_create` but rejects any row whose parent is an
unsaved instance. Django's bulk_create builds the multi-row INSERT VALUES
up front from each instance's `parent_id`; an unsaved parent's pk is not
assigned until the INSERT's RETURNING clause executes, so a child
referencing an unsaved parent (even one earlier in the same batch) goes
in with parent_id=NULL and the BEFORE trigger stores it as a root. Save
the parents first, then bulk_create their children.
"""
objs_list = list(objs)
for idx, obj in enumerate(objs_list):
parent = getattr(obj, 'parent', None)
if parent is not None and parent.pk is None:
raise ValueError(
"bulk_create: child at index {idx} references an unsaved parent. "
"Django cannot propagate the parent's RETURNING-assigned pk into "
"the child's parent_id before the INSERT executes, so the child "
"would be persisted with parent_id=NULL and stored as a root. "
"Save the parent first, then bulk_create the children.".format(idx=idx)
)
return super().bulk_create(objs_list, *args, **kwargs)
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).
The six historical variants (3 relation kinds × cumulative/not) are
assembled from two fragments: how a related row links to a tree node
(`link_expr` + any join/scope filter), and which node is counted the
parent row itself (non-cumulative) or any node in its subtree via `<@`
(cumulative).
"""
try:
field = model._meta.get_field(rel_field)
except Exception:
field = None
is_many_to_many = isinstance(field, ManyToManyField)
has_direct_fk = isinstance(field, ForeignKey)
has_generic_fk = (
hasattr(model, 'scope_type') and hasattr(model, 'scope_id')
and not has_direct_fk and not is_many_to_many
)
qn = connection.ops.quote_name
parent_table = qn(queryset.model._meta.db_table)
related_table = qn(model._meta.db_table)
# `from_join` is the FROM (+ m2m join); `link_expr` is the column that points
# at a tree node's id; `scope_filter` constrains the generic-FK content type.
params = []
from_join = f'FROM {related_table}'
scope_filter = ''
if is_many_to_many:
# m2m_column_name() points at the declaring model (`model`);
# m2m_reverse_name() points at the related (tree) model.
m2m_table = qn(field.remote_field.through._meta.db_table)
from_join += (
f' INNER JOIN {m2m_table}'
f' ON {related_table}."id" = {m2m_table}.{qn(field.m2m_column_name())}'
)
link_expr = f'{m2m_table}.{qn(field.m2m_reverse_name())}'
elif has_generic_fk:
link_expr = f'{related_table}."scope_id"'
# Resolve scope_type_id via subquery so the annotation can be built at
# import time (e.g. in a view class body) before contenttypes migrate.
scope_filter = (
f' AND {related_table}."scope_type_id" = ('
'SELECT id FROM django_content_type WHERE app_label = %s AND model = %s)'
)
params = [queryset.model._meta.app_label, queryset.model._meta.model_name]
else:
# field.column honors a custom db_column; fall back to Django's default
# `{rel_field}_id` if the field was not resolved (a renamed unrelated
# field must not break import-time annotation construction).
rel_field_col = qn(field.column if field is not None else f'{rel_field}_id')
link_expr = f'{related_table}.{rel_field_col}'
if cumulative:
node_join = f' INNER JOIN {parent_table} AS subtree ON {link_expr} = subtree."id"'
where = f'WHERE subtree."path" <@ {parent_table}."path"{scope_filter}'
else:
node_join = ''
where = f'WHERE {link_expr} = {parent_table}."id"{scope_filter}'
sql = f'(SELECT COUNT(DISTINCT {related_table}."id") {from_join}{node_join} {where})'
return queryset.annotate(**{
count_attr: RawSQL(sql, params, output_field=models.IntegerField())
})
class LtreeManager(models.Manager.from_queryset(LtreeQuerySet)):
"""Drop-in replacement for django-mptt's TreeManager."""
#
# Abstract model
#
class LtreeModelBase(models.base.ModelBase):
"""
Metaclass that keeps a model's `sort_path` collation in sync with its `name`.
`sort_path` holds a chr(9)-joined chain of ancestor names, so to flatten siblings
in the same order the database sorts `name`, the two columns must share a collation.
Deriving it here means a subclass that gives `name` a custom db_collation (e.g.
`natural_sort`) automatically gets a matching `sort_path` no need to redeclare the
field just to repeat the collation. An explicit db_collation on `sort_path` is left
untouched.
"""
def __new__(mcs, name, bases, namespace, **kwargs):
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
if cls._meta.abstract:
return cls
try:
name_field = cls._meta.get_field('name')
sort_path_field = cls._meta.get_field('sort_path')
except FieldDoesNotExist:
return cls
name_collation = getattr(name_field, 'db_collation', None)
if name_collation and not getattr(sort_path_field, 'db_collation', None):
sort_path_field.db_collation = name_collation
return cls
class LtreeModel(models.Model, metaclass=LtreeModelBase):
"""
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.
Bulk creates:
The BEFORE INSERT trigger resolves a row's parent by SELECTing `path`
from the same table by parent_id. LtreeQuerySet.bulk_create() rejects any
row whose parent is an *unsaved* instance, so in normal use parents
saved before their children are bulk-created batch order does not matter
(each parent row already exists for the lookup). The lone exception is
manually pre-assigned PKs: if a child references a same-batch parent by a
hand-set pk, that parent must appear earlier in the batch (the BEFORE
trigger fires per row in list order), or the child gets a root-level path.
Sort-path on rename:
For subclasses with the optional `sort_path` column (see
InstallLtreeTriggers' `name_column` arg), renaming a row updates its
own sort_path AND cascades into descendants' sort_paths via the AFTER
trigger. This diverges from django-mptt's `order_insertion_by` (which
leaves both the renamed row and its descendants stale until a manual
rebuild) because list views are expected to reflect renames promptly.
`rebuild_sort_paths()` is still available for bulk repair after raw
SQL writes that bypass the triggers.
Concurrency:
Path maintenance takes no table-wide lock (unlike django-mptt, which
acquired a per-model advisory lock on *every* write to protect its global
lft/rght/tree_id numbering). Instead, the BEFORE trigger serializes per
tree: it takes a transaction-level advisory lock keyed on the root of the
tree being written (and, for a cross-tree move, on both the source and
destination roots, acquired in ascending key order to avoid deadlocks).
Every child insert, move, and reparent of a node in a tree takes the same
key, so an insert deep in a subtree and a concurrent reparent of one of its
ancestors are serialized the loser blocks until the winner commits, and
the winner's AFTER cascade can never miss a row inserted concurrently
(which a row-level `FOR SHARE` on the parent could not prevent: a set-based
cascade's snapshot would skip a row inserted after it began). Writes to
*different* trees use different keys and proceed fully in parallel e.g.
inventory ingestion across different devices, each its own tree.
Inserting a *new root* (parent_id IS NULL) takes no lock: the uncommitted
row is invisible to other transactions and has no descendants, so nothing
can contend with it. This keeps a bulk import of many top-level objects
(the dominant import pattern) lock-free instead of taking one advisory lock
per root. The residual case that still scales with volume is inserting
children into many *distinct existing* trees in one transaction (one lock
per distinct tree touched); if a very large such import hits "out of shared
memory", raising the server's `max_locks_per_transaction` is the remedy.
Two residual, retryable cases remain (PostgreSQL aborts one transaction
with a deadlock error rather than persisting a stale path): crossing
reparents (moving A under B while moving B under A), and two concurrent
*moves* in an ancestor/descendant relationship (a move locks the moved
row before its BEFORE trigger can take the advisory lock). Plain inserts
the high-volume path never hit this.
"""
# `default=''` here is a Django-side placeholder that the BEFORE INSERT
# trigger always overwrites with a valid path before the row reaches
# storage. Empty ltree (`''`) is itself a valid PostgreSQL ltree value
# (nlevel = 0) in supported PostgreSQL versions (15+), so even harnesses
# that bypass the trigger will not fail at INSERT — they will simply
# store a zero-level path.
path = LtreeField(editable=False, null=False, blank=True, default='')
objects = LtreeManager()
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Read from __dict__ rather than via attribute access: a deferred
# `parent_id`/`name` (e.g. a GraphQL query selecting only a subset of
# fields) must not be lazily loaded here, since that triggers
# refresh_from_db() which rebuilds the instance and recurses into __init__.
self._loaded_parent_id = self.__dict__.get('parent_id')
self._loaded_name = self.__dict__.get('name')
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
instance._loaded_parent_id = instance.__dict__.get('parent_id')
instance._loaded_name = instance.__dict__.get('name')
return instance
def _parent_creates_cycle(self):
"""
Return True if the current `parent` assignment would make this node its
own ancestor (the new parent is self or one of its descendants), mirroring
django-mptt's save-time InvalidMove guard.
Subclasses whose `parent` is system-managed (e.g. ModuleBay, whose parent
is derived from its module) may override this to disable the check.
"""
if self.parent_id is None:
return False
# Self-as-parent is always a cycle and must be caught even if self.path
# is empty or deferred (path would otherwise short-circuit below).
if self.parent_id == self.pk:
return True
if not self.path:
return False
# The new parent lies inside this node's current subtree iff its path is a
# descendant of (or equal to) self.path.
return type(self)._default_manager.filter(
pk=self.parent_id, path__descendant_or_equal=self.path
).exists()
@classmethod
def _has_sort_path(cls):
"""
Whether this model carries the optional trigger-maintained `sort_path`
column (the MPTT `order_insertion_by=('name',)` equivalent). Single source
of truth for clean(), save(), and _tree_order_field().
"""
try:
cls._meta.get_field('sort_path')
return True
except FieldDoesNotExist:
return False
def clean(self):
"""
Reject assigning self or a descendant as parent, surfacing it as a field
error for forms/serializers. This mirrors the save()-time guard; the two
share _parent_creates_cycle() so the rule lives in exactly one place.
Subclasses whose `parent` is system-managed (e.g. ModuleBay) disable the
check by overriding _parent_creates_cycle() to return False.
For sort_path-backed models, also reject a tab in the name column: sort_path
joins ancestor names with chr(9) (TAB), so a literal tab in a name would
corrupt sibling ordering for the node and its descendants.
"""
super().clean()
if self.pk and self._parent_creates_cycle():
raise ValidationError({
"parent": _("Cannot assign self or a descendant as parent.")
})
if self._has_sort_path() and '\t' in (getattr(self, 'name', None) or ''):
raise ValidationError({
"name": _("Name cannot contain tab characters.")
})
def save(self, *args, **kwargs):
"""
Triggers compute `path` (and `sort_path`, where present) server-side.
On INSERT the trigger-maintained columns are db_returning, so they are
populated in-place by the INSERT ... RETURNING clause without an extra
query. On an UPDATE that changes `parent` or the name column the triggers
rewrite those columns server-side, so refresh them afterward to keep the
in-memory instance consistent (e.g. so change logging snapshots the value
the triggers actually wrote, not a stale one).
"""
is_insert = self._state.adding
# When update_fields is supplied and excludes parent, the DB does not see
# the new parent_id, so the trigger does not fire and _loaded_parent_id
# must not advance — otherwise a subsequent full save() would mis-detect
# the (real) parent change as already-applied and leave path stale.
update_fields = kwargs.get('update_fields')
parent_written = update_fields is None or 'parent' in update_fields or 'parent_id' in update_fields
parent_changed = (not is_insert) and parent_written and self.parent_id != self._loaded_parent_id
# Reject cyclic moves before writing, mirroring django-mptt's save-time
# guard so scripts / bulk callers (which bypass form & serializer clean())
# cannot silently corrupt the tree.
if parent_changed and self._parent_creates_cycle():
raise ValidationError(_("Cannot assign self or a descendant as parent."))
# The sort_path trigger also fires on a name change; detect that so the
# cascaded sort_path can be refreshed below (path-only models have no
# sort_path and are unaffected by renames).
has_sort_path = self._has_sort_path()
name_written = update_fields is None or 'name' in update_fields
name_changed = (
(not is_insert) and has_sort_path and name_written
and self.__dict__.get('name') != self._loaded_name
)
try:
super().save(*args, **kwargs)
except IntegrityError as exc:
# A concurrent reparent that races the Python-level _parent_creates_cycle
# check is caught by the BEFORE trigger, which RAISEs 'cycle detected ...'
# with ERRCODE = check_violation (SQLSTATE 23514). Gate on the SQLSTATE
# (the primary, stable signal) AND the message marker, so an unrelated
# CHECK constraint on a subclass (also 23514) is not misreported as a
# cycle. Surface it as a ValidationError so the API/UI returns 400 instead
# of the IntegrityError → 500 the trigger would otherwise produce.
if (
getattr(exc.__cause__, 'sqlstate', None) == '23514'
and 'cycle detected' in str(exc)
):
raise ValidationError(
_("Cannot assign self or a descendant as parent.")
) from None
# The BEFORE trigger likewise rejects a tab in the name (it would corrupt
# sort_path); translate it for direct save() calls that skip clean().
if (
getattr(exc.__cause__, 'sqlstate', None) == '23514'
and 'tab character' in str(exc)
):
raise ValidationError({
"name": _("Name cannot contain tab characters.")
}) from None
raise
except OperationalError as exc:
# The per-tree advisory locks can deadlock on crossing reparents or two
# concurrent ancestor/descendant moves; PostgreSQL aborts one with
# SQLSTATE 40P01. Surface a clear, retryable message instead of the
# opaque 500 the bare OperationalError would produce.
if getattr(exc.__cause__, 'sqlstate', None) == '40P01':
raise ValidationError(
_("The hierarchy was modified concurrently; please retry.")
) from None
raise
if (parent_changed or name_changed) and not is_insert:
# The triggers rewrote path/sort_path on this UPDATE; fetch them back so
# the in-memory instance matches storage (e.g. so change logging snapshots
# the value the triggers wrote, not a stale one). This costs one extra
# SELECT per reparent/rename; INSERT ... RETURNING covers the insert case,
# so only updates reach here.
refresh_fields = ['path'] + (['sort_path'] if has_sort_path else [])
self.refresh_from_db(fields=refresh_fields)
if is_insert or parent_written:
self._loaded_parent_id = self.parent_id
if is_insert or name_written:
self._loaded_name = self.__dict__.get('name')
# -- 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
@classmethod
def _tree_order_field(cls):
"""
Field name to order hierarchical queries by. Models that carry a
`sort_path` column (the MPTT `order_insertion_by=('name',)` equivalent)
order siblings by name to match the prior MPTT behavior; models
without it fall back to `path` (PK-padded, insertion order).
"""
return 'sort_path' if cls._has_sort_path() else 'path'
def get_ancestors(self, ascending=False, include_self=False):
if not self.path:
return type(self)._default_manager.none()
lookup = 'ancestor_or_equal' if include_self else 'ancestor'
qs = type(self)._default_manager.filter(**{f'path__{lookup}': self.path})
order_field = self._tree_order_field()
return qs.order_by(f'-{order_field}' if ascending else order_field)
def get_descendants(self, include_self=False):
if not self.path:
return type(self)._default_manager.none()
lookup = 'descendant_or_equal' if include_self else 'descendant'
return type(self)._default_manager.filter(
**{f'path__{lookup}': self.path}
).order_by(self._tree_order_field())
def get_children(self):
return type(self)._default_manager.filter(parent_id=self.pk).order_by(self._tree_order_field())
@classmethod
def rebuild_sort_paths(cls, name_column='name'):
"""
Recompute `sort_path` for every row from current values of `name_column`.
Inserts, reparents, AND renames are all maintained automatically by the
BEFORE/AFTER triggers (the BEFORE trigger fires on INSERT and on updates to
parent_id or the name column). Use this only to repair `sort_path` after a
raw SQL write (e.g. a bulk COPY or a direct UPDATE) that bypassed those
triggers.
Raises if the table does not have a `sort_path` column.
"""
from django.db import connection
if not cls._has_sort_path():
raise NotImplementedError(
f"{cls.__name__} does not have a sort_path column"
)
qn = connection.ops.quote_name
table = qn(cls._meta.db_table)
name_col = qn(name_column)
sql = f'''
WITH RECURSIVE t(id, parent_id, sort_path) AS (
SELECT id, parent_id, {name_col}::text
FROM {table} WHERE parent_id IS NULL
UNION ALL
SELECT r.id, r.parent_id, t.sort_path || chr(9) || r.{name_col}
FROM {table} r INNER JOIN t ON r.parent_id = t.id
)
UPDATE {table} SET sort_path = t.sort_path FROM t WHERE {table}.id = t.id;
'''
with connection.cursor() as cursor:
cursor.execute(sql)

View File

@ -45,6 +45,7 @@ __all__ = (
'TagColumn',
'TemplateColumn',
'ToggleColumn',
'TreeColumn',
'UtilizationColumn',
)
@ -604,9 +605,9 @@ class CustomLinkColumn(tables.Column):
return None
class MPTTColumn(tables.TemplateColumn):
class TreeColumn(tables.TemplateColumn):
"""
Display a nested hierarchy for MPTT-enabled models.
Display a nested hierarchy for tree-enabled models (Region, Location, etc.).
"""
template_code = """
{% load helpers %}
@ -628,6 +629,11 @@ class MPTTColumn(tables.TemplateColumn):
return value
# Deprecated alias for plugin compatibility; use TreeColumn going forward.
# TODO: Remove this in NetBox v5.0
MPTTColumn = TreeColumn
class UtilizationColumn(tables.TemplateColumn):
"""
Display a colored utilization bar graph.

View File

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

View File

@ -40,11 +40,12 @@ from netbox.forms.model_forms import (
)
from netbox.graphql.types import (
NestedGroupObjectType,
NestedLtreeGroupObjectType,
NetBoxObjectType,
OrganizationalObjectType,
PrimaryObjectType,
)
from netbox.models import NestedGroupModel, NetBoxModel, OrganizationalModel, PrimaryModel
from netbox.models import NestedGroupModelMixin, NestedLtreeGroupModel, NetBoxModel, OrganizationalModel, PrimaryModel
from netbox.registry import registry
from netbox.tables import (
NestedGroupModelTable,
@ -77,7 +78,7 @@ class FormClassesTestCase(TestCase):
return PrimaryModelForm
if issubclass(model, OrganizationalModel):
return OrganizationalModelForm
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelForm
if issubclass(model, NetBoxModel):
return NetBoxModelForm
@ -94,7 +95,7 @@ class FormClassesTestCase(TestCase):
return PrimaryModelBulkEditForm
if issubclass(model, OrganizationalModel):
return OrganizationalModelBulkEditForm
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelBulkEditForm
if issubclass(model, NetBoxModel):
return NetBoxModelBulkEditForm
@ -111,7 +112,7 @@ class FormClassesTestCase(TestCase):
return PrimaryModelImportForm
if issubclass(model, OrganizationalModel):
return OrganizationalModelImportForm
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelImportForm
if issubclass(model, NetBoxModel):
return NetBoxModelImportForm
@ -128,7 +129,7 @@ class FormClassesTestCase(TestCase):
return PrimaryModelFilterSetForm
if issubclass(model, OrganizationalModel):
return OrganizationalModelFilterSetForm
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelFilterSetForm
if issubclass(model, NetBoxModel):
return NetBoxModelFilterSetForm
@ -192,7 +193,7 @@ class FilterSetClassesTestCase(TestCase):
return PrimaryModelFilterSet
if issubclass(model, OrganizationalModel):
return OrganizationalModelFilterSet
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelFilterSet
if issubclass(model, NetBoxModel):
return NetBoxModelFilterSet
@ -243,7 +244,7 @@ class TableClassesTestCase(TestCase):
return PrimaryModelTable
if issubclass(model, OrganizationalModel):
return OrganizationalModelTable
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelTable
if issubclass(model, NetBoxModel):
return NetBoxTable
@ -324,7 +325,7 @@ class SerializerClassesTestCase(TestCase):
return PrimaryModelSerializer
if issubclass(model, OrganizationalModel):
return OrganizationalModelSerializer
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedGroupModelMixin):
return NestedGroupModelSerializer
if issubclass(model, NetBoxModel):
return NetBoxModelSerializer
@ -365,7 +366,9 @@ class GraphQLTypeClassesTestCase(TestCase):
return PrimaryObjectType
if issubclass(model, OrganizationalModel):
return OrganizationalObjectType
if issubclass(model, NestedGroupModel):
if issubclass(model, NestedLtreeGroupModel):
return NestedLtreeGroupObjectType
if issubclass(model, NestedGroupModelMixin):
return NestedGroupObjectType
if issubclass(model, NetBoxModel):
return NetBoxObjectType

View File

@ -1,6 +1,7 @@
import logging
import re
from collections import Counter
from contextlib import contextmanager
from copy import deepcopy
from types import SimpleNamespace
@ -16,7 +17,6 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from mptt.models import MPTTModel
from core.exceptions import JobFailed
from core.models import ObjectType
@ -55,6 +55,35 @@ __all__ = (
)
# TODO: Remove in NetBox v5.0.
# MPTT support is retained only for plugins whose tree models still derive from the
# deprecated MPTT-backed bases. NetBox core uses netbox.models.ltree.LtreeModel, whose
# database triggers maintain the tree on every write, so ltree (and non-tree) models
# need no special bulk handling. These two helpers confine all MPTT-specific bulk
# bookkeeping so it can be deleted in one place; for non-MPTT models they are no-ops.
@contextmanager
def _delay_mptt_updates(model):
"""
Defer tree (lft/rght/tree_id) recomputation until the end of a bulk write for
legacy MPTT models. A no-op context manager for ltree and non-tree models.
"""
from mptt.models import MPTTModel
if issubclass(model, MPTTModel):
with model.objects.delay_mptt_updates():
yield
else:
yield
def _rebuild_mptt_tree(model):
"""Rebuild the MPTT tree after a bulk edit for legacy MPTT models; else a no-op."""
from mptt.models import MPTTModel
if issubclass(model, MPTTModel):
model.objects.rebuild()
class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
"""
Display multiple objects, all the same type, as a table.
@ -563,11 +592,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
# For MPTT models, delay tree updates until all saves are complete
if issubclass(self.queryset.model, MPTTModel):
with self.queryset.model.objects.delay_mptt_updates():
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
else:
# Delay tree updates until all saves are complete (MPTT plugin models only;
# no-op for ltree). TODO: Remove the wrapper in v5.0 (see _delay_mptt_updates).
with _delay_mptt_updates(self.queryset.model):
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
return saved_objects
@ -762,9 +789,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if is_background_request(request):
request.job.logger.info(f"Updated {obj}")
# Rebuild the tree for MPTT models
if issubclass(self.queryset.model, MPTTModel):
self.queryset.model.objects.rebuild()
# Rebuild the tree for MPTT plugin models (no-op for ltree; its triggers keep
# the tree current). TODO: Remove in v5.0 (see _rebuild_mptt_tree).
_rebuild_mptt_tree(self.queryset.model)
return updated_objects
@ -974,15 +1001,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = self._rename_objects(form, selected_objects, field_names)
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:
for field in field_names:
setattr(obj, field, getattr(obj.new_names, field))
obj._changelog_message = form.cleaned_data.get('changelog_message', '')
obj.save()
else:
# Delay tree updates until all saves are complete (MPTT
# plugin models only; no-op for ltree).
# TODO: Remove the wrapper in v5.0 (see _delay_mptt_updates).
with _delay_mptt_updates(self.queryset.model):
for obj in selected_objects:
for field in field_names:
setattr(obj, field, getattr(obj.new_names, field))

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import strawberry
import strawberry_django
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, NestedGroupObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, NestedLtreeGroupObjectType, OrganizationalObjectType, PrimaryObjectType
from tenancy import models
from .filters import *
@ -88,11 +88,11 @@ class TenantType(ContactsMixin, PrimaryObjectType):
@strawberry_django.type(
models.TenantGroup,
fields='__all__',
exclude=['path', 'sort_path'],
filters=TenantGroupFilter,
pagination=True
)
class TenantGroupType(NestedGroupObjectType):
class TenantGroupType(NestedLtreeGroupObjectType):
parent: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None
tenants: list[TenantType]
@ -125,11 +125,11 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
@strawberry_django.type(
models.ContactGroup,
fields='__all__',
exclude=['path', 'sort_path'],
filters=ContactGroupFilter,
pagination=True
)
class ContactGroupType(NestedGroupObjectType):
class ContactGroupType(NestedLtreeGroupObjectType):
parent: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None
contacts: list[ContactType]

View File

@ -0,0 +1,124 @@
"""Replace django-mptt with PostgreSQL ltree for tenancy's hierarchical models.
The reverse migration is lossy: it re-adds the MPTT columns empty and does not
rebuild the tree. Forward migration is the supported direction.
"""
import django.db.models.deletion
from django.contrib.postgres.indexes import GistIndex
from django.contrib.postgres.operations import CreateExtension
from django.db import migrations, models
import netbox.models.ltree
from utilities.ltree import InstallLtreeTriggers
from utilities.mptt_to_ltree import assert_paths_populated_sql, populate_paths_sql
MODELS = ('tenantgroup', 'contactgroup')
TABLES = ('tenancy_tenantgroup', 'tenancy_contactgroup')
LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0024_default_ordering_indexes'),
]
operations = [
# Enable the ltree extension first so the migration fails fast if it is missing.
CreateExtension('ltree'),
# Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey.
migrations.AlterField(
model_name='contactgroup', name='parent',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='tenancy.contactgroup',
),
),
migrations.AlterField(
model_name='tenantgroup', name='parent',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='tenancy.tenantgroup',
),
),
# Add path (nullable initially) on both models.
*[
migrations.AddField(
model_name=m, name='path',
field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
)
for m in MODELS
],
# Add sort_path. TenantGroup gets natural_sort collation (matching its name field).
migrations.AddField(
model_name='contactgroup', name='sort_path',
field=models.TextField(blank=True, default='', editable=False),
),
migrations.AddField(
model_name='tenantgroup', name='sort_path',
field=models.TextField(
blank=True, default='', editable=False, db_collation='natural_sort',
),
),
# Install triggers maintaining both path and sort_path.
*[InstallLtreeTriggers(t, name_column='name') for t in TABLES],
# Populate path and sort_path for existing rows via per-table recursive CTE.
migrations.RunSQL(
'\n'.join(populate_paths_sql(t, sort_path=True) for t in TABLES),
reverse_sql=migrations.RunSQL.noop,
),
# Fail fast if any row still has NULL path (orphan FKs) before the
# AlterField below tries to set NOT NULL inside ALTER COLUMN.
migrations.RunSQL(
'\n'.join(assert_paths_populated_sql(t) for t in TABLES),
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
],
migrations.AlterModelOptions(
name='contactgroup', options={'ordering': ('sort_path',)},
),
migrations.AlterModelOptions(
name='tenantgroup', options={'ordering': ('sort_path',)},
),
# Drop legacy (tree_id, lft) indexes and the 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
],
# GiST indexes on path.
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'),
),
# Btree indexes on sort_path for ORDER BY listing.
migrations.AddIndex(
model_name='tenantgroup',
index=models.Index(fields=['sort_path'], name='tenancy_tg_sort_path_idx'),
),
migrations.AddIndex(
model_name='contactgroup',
index=models.Index(fields=['sort_path'], name='tenancy_cg_sort_path_idx'),
),
]

View File

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

View File

@ -1,8 +1,9 @@
from django.contrib.postgres.indexes import GistIndex
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models import NestedLtreeGroupModel, PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = (
@ -11,7 +12,7 @@ __all__ = (
)
class TenantGroup(NestedGroupModel):
class TenantGroup(NestedLtreeGroupModel):
"""
An arbitrary collection of Tenants.
"""
@ -26,12 +27,14 @@ class TenantGroup(NestedGroupModel):
max_length=100,
unique=True
)
# sort_path inherits natural_sort collation from `name` automatically (LtreeModelBase).
class Meta:
ordering = ['name']
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='tenancy_tenantgroup_path_gist'),
models.Index(fields=['sort_path'], name='tenancy_tg_sort_path_idx'),
)
verbose_name = _('tenant group')
verbose_name_plural = _('tenant groups')

293
netbox/utilities/ltree.py Normal file
View File

@ -0,0 +1,293 @@
"""
Migration support for ltree-based hierarchical models.
This module holds the schema-level machinery that backs `netbox.models.ltree`:
the PostgreSQL trigger function / trigger SQL and the `InstallLtreeTriggers`
migration operation that installs them. It is kept separate from the model layer
(`netbox.models.ltree`) so that migrations depend only on this DB-level code and
not on the model definitions.
The paths maintained by these triggers are never computed or mutated from Python;
the model layer only reads `path`/`sort_path` back from the database.
"""
from django.db import migrations
__all__ = (
'InstallLtreeTriggers',
)
# Path label is the row's PK zero-padded to 19 chars (max bigint width) so that
# lexicographic ordering of ltree labels matches numeric PK ordering across digit
# boundaries (e.g. "0...09" sorts before "0...10").
# Per-tree advisory locking (see _lock_tree_roots_sql / LtreeModel "Concurrency"):
# every child insert / move / reparent of a node takes a transaction-level
# advisory lock keyed on the root(s) of the tree(s) it touches, BEFORE reading the
# parent path. A concurrent reparent of an ancestor takes the same key, so the two
# serialize and the AFTER cascade can never miss a row inserted concurrently;
# writes in different trees use different keys and run in parallel. Inserting a new
# root (parent_id IS NULL) takes no lock at all -- it is a race-free singleton tree
# -- so a bulk import of many top-level objects does not accumulate one lock per
# root and cannot exhaust the shared lock table.
_LOCK_TREE_ROOTS_SQL = '''
-- A brand-new root (INSERT with parent_id IS NULL) starts its own singleton
-- tree that no concurrent transaction can yet see (MVCC: the uncommitted row
-- is invisible) or reference (no other transaction has its PK). For such a
-- row this BEFORE function reads no other row (the parent lookup below is
-- gated on parent_id) and the AFTER cascade fires only on UPDATE, so the
-- insert touches solely its own NEW row -- there is nothing to serialize
-- against, and the advisory lock it would otherwise take can never contend.
-- Skipping it here is what stops a bulk import of many top-level objects from
-- taking one xact-lock per root and exhausting the shared lock table
-- (sized by max_locks_per_transaction). Every other case still locks below:
-- a child insert, any reparent, and a reparent-to-root (TG_OP = UPDATE, where
-- the existing row has a real subtree the AFTER cascade must rewrite).
IF NOT (TG_OP = 'INSERT' AND NEW.parent_id IS NULL) THEN
-- Destination tree root: the parent's root label, or this row's own label
-- when it is (or becomes) a root. The CASE guards against a parent whose path
-- is the empty ltree '' (reachable only via a trigger-bypassing raw write):
-- subltree('', 0, 1) would raise 'invalid positions', so fall back to the
-- child's own label as the lock key rather than aborting the insert/move.
IF NEW.parent_id IS NOT NULL THEN
EXECUTE format(
'SELECT CASE WHEN nlevel(path) > 0 THEN subltree(path, 0, 1)::text END'
' FROM %%I WHERE id = $1',
TG_TABLE_NAME
) INTO dest_root USING NEW.parent_id;
END IF;
dest_root := COALESCE(dest_root, lpad(NEW.id::text, 19, '0'));
-- Source tree root (moves only): this row's current root, which the AFTER
-- cascade will rewrite.
IF TG_OP = 'UPDATE' AND OLD.path IS NOT NULL AND nlevel(OLD.path) > 0 THEN
old_root := subltree(OLD.path, 0, 1)::text;
END IF;
key_dest := hashtextextended(TG_TABLE_NAME || ':' || dest_root, 0);
IF old_root IS NOT NULL AND old_root <> dest_root THEN
-- Cross-tree move: lock both roots, ascending, to avoid deadlock between
-- two concurrent moves that touch the same pair.
key_old := hashtextextended(TG_TABLE_NAME || ':' || old_root, 0);
PERFORM pg_advisory_xact_lock(LEAST(key_dest, key_old));
PERFORM pg_advisory_xact_lock(GREATEST(key_dest, key_old));
ELSE
PERFORM pg_advisory_xact_lock(key_dest);
END IF;
END IF;
'''
_COMPUTE_PATH_ONLY_FN = '''
CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
DECLARE
parent_path ltree;
dest_root text;
old_root text;
key_dest bigint;
key_old bigint;
BEGIN
''' + _LOCK_TREE_ROOTS_SQL + '''
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;
-- Cycle guard. The Python LtreeModel.save() also rejects cyclic moves,
-- but a QuerySet.update() / bulk_update() bypasses save() entirely, so
-- catch the case here as a last line of defense. A cycle exists iff
-- this row's own label appears anywhere in parent_path (the row would
-- become its own ancestor). Match the label as any segment via lquery.
IF parent_path ~ ('*.' || lpad(NEW.id::text, 19, '0') || '.*')::lquery
OR parent_path = lpad(NEW.id::text, 19, '0')::ltree THEN
RAISE EXCEPTION 'cycle detected: %% cannot be its own ancestor', TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END IF;
NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
ELSE
NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
END IF;
RETURN NEW;
END
$$ LANGUAGE plpgsql;
'''
_CASCADE_PATH_ONLY_FN = '''
CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
BEGIN
-- `nlevel($2) > 0` guards against an empty OLD.path ('', reachable only via a
-- trigger-bypassing raw write): `path <@ ''` is true for EVERY row, so without
-- this the cascade would rewrite the entire table on one reparent.
EXECUTE format(
'UPDATE %%I SET path = $1 || subpath(path, nlevel($2))'
' WHERE nlevel($2) > 0 AND path <@ $2 AND id != $3',
TG_TABLE_NAME
) USING NEW.path, OLD.path, NEW.id;
RETURN NULL;
END
$$ LANGUAGE plpgsql;
'''
# For models with order_insertion_by=(name,) — maintain a second text column
# `sort_path` whose value is the chain of ancestor names joined by chr(9) (TAB).
# TAB sorts strictly below any printable character under both the default text
# collation and the ICU `natural_sort` collation (which is `und-u-kn-true`).
# ICU collations with default variable weighting treat U+0001..U+0008 as
# variable-ignorable, so a chr(1) separator under natural_sort would interleave
# children with unrelated roots; TAB is given a primary weight and orders
# deterministically. ORDER BY sort_path then gives MPTT-equivalent
# tree-flatten ordering with siblings in name (collation) order.
#
# The BEFORE trigger fires on INSERT, parent_id changes, and name changes, so
# a rename updates the row's own sort_path; the AFTER trigger then cascades
# the new sort_path into descendants. (django-mptt's `order_insertion_by`
# stops at the renamed node and leaves descendants stale until a manual
# rebuild — NetBox auto-cascades because operators expect renames to flow
# through. `rebuild_sort_paths()` is still available for bulk repair.)
_COMPUTE_PATH_AND_SORT_FN = '''
CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
DECLARE
parent_path ltree;
parent_sort_path text;
dest_root text;
old_root text;
key_dest bigint;
key_old bigint;
BEGIN
''' + _LOCK_TREE_ROOTS_SQL + '''
-- sort_path joins ancestor names with chr(9) (TAB); a literal tab in a name
-- would inject a spurious separator and corrupt sibling ordering for the node
-- and its descendants. LtreeModel.clean() rejects this for forms/serializers;
-- this is the backstop for bulk_create / scripts / raw writes that bypass clean().
IF position(chr(9) in COALESCE(NEW."{name_col}", '')) > 0 THEN
RAISE EXCEPTION 'name contains a tab character, which is not allowed'
USING ERRCODE = 'check_violation';
END IF;
IF NEW.parent_id IS NOT NULL THEN
EXECUTE format('SELECT path, sort_path FROM %%I WHERE id = $1', TG_TABLE_NAME)
INTO parent_path, parent_sort_path USING NEW.parent_id;
-- Cycle guard. See _COMPUTE_PATH_ONLY_FN for the rationale; this catches
-- raw UPDATE / bulk_update paths that bypass LtreeModel.save().
IF parent_path ~ ('*.' || lpad(NEW.id::text, 19, '0') || '.*')::lquery
OR parent_path = lpad(NEW.id::text, 19, '0')::ltree THEN
RAISE EXCEPTION 'cycle detected: %% cannot be its own ancestor', TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END IF;
NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
NEW.sort_path := parent_sort_path || chr(9) || NEW."{name_col}";
ELSE
NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
NEW.sort_path := NEW."{name_col}";
END IF;
RETURN NEW;
END
$$ LANGUAGE plpgsql;
'''
_CASCADE_PATH_AND_SORT_FN = """
CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
BEGIN
-- COALESCE guards against a NULL sort_path slipping in via a raw write that
-- bypassed the BEFORE trigger: without it, length(NULL)/substring(... FROM NULL)
-- would cascade NULL to every descendant's sort_path in one shot.
-- `nlevel($2) > 0` guards against an empty OLD.path ('', reachable only via a
-- trigger-bypassing raw write): `path <@ ''` is true for EVERY row, so without
-- this the cascade would rewrite the entire table on one reparent.
EXECUTE format(
'UPDATE %%I SET '
' path = $1 || subpath(path, nlevel($2)), '
' sort_path = COALESCE($4, '''') || substring(COALESCE(sort_path, '''') FROM length(COALESCE($5, '''')) + 1) '
'WHERE nlevel($2) > 0 AND path <@ $2 AND id != $3',
TG_TABLE_NAME
) USING NEW.path, OLD.path, NEW.id, NEW.sort_path, OLD.sort_path;
RETURN NULL;
END
$$ LANGUAGE plpgsql;
"""
_BEFORE_TRIGGER_PATH_ONLY = '''
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"();
'''
# For path+sort tables, also fire on UPDATE OF {name_col} so that renaming a
# node recomputes its sort_path. The cascade trigger then propagates the new
# sort_path to descendants.
_BEFORE_TRIGGER_PATH_AND_SORT = '''
CREATE TRIGGER "{table}_ltree_compute_path"
BEFORE INSERT OR UPDATE OF parent_id, "{name_col}" ON "{table}"
FOR EACH ROW EXECUTE FUNCTION "{table}_ltree_compute_path_fn"();
'''
# AFTER trigger fires on the columns that operators / Django write directly
# (parent_id and the name column) — NOT on path or sort_path. The cascade
# function rewrites path/sort_path on descendants in a single statement, and
# because that statement does not touch parent_id or {name_col}, the AFTER
# trigger does not re-fire on those descendant rows. This prevents the
# quadratic re-cascade that would otherwise occur for any deep subtree.
_AFTER_TRIGGER_PATH_ONLY = '''
CREATE TRIGGER "{table}_ltree_cascade_path"
AFTER UPDATE OF parent_id ON "{table}"
FOR EACH ROW WHEN (OLD.path IS DISTINCT FROM NEW.path)
EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
'''
_AFTER_TRIGGER_PATH_AND_SORT = '''
CREATE TRIGGER "{table}_ltree_cascade_path"
AFTER UPDATE OF parent_id, "{name_col}" ON "{table}"
FOR EACH ROW WHEN (
OLD.path IS DISTINCT FROM NEW.path
OR OLD.sort_path IS DISTINCT FROM NEW.sort_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 (and sort_path if applicable)
AFTER UPDATE OF parent_id -> cascade path/sort_path change to descendants
If `name_column` is provided, the model is expected to have a `sort_path`
text column whose value will be maintained as a chr(9)-separated chain of
ancestor names. This implements MPTT's `order_insertion_by=(name,)`
semantics: insert, reparent, and rename all honor the current value of
`name_column`, with renames cascaded into descendants' sort_paths.
"""
reversible = True
def __init__(self, table_name, name_column=None):
self.table_name = table_name
self.name_column = name_column
def state_forwards(self, app_label, state):
pass
def database_forwards(self, app_label, schema_editor, from_state, to_state):
if self.name_column:
schema_editor.execute(_COMPUTE_PATH_AND_SORT_FN.format(
table=self.table_name, name_col=self.name_column,
))
schema_editor.execute(_CASCADE_PATH_AND_SORT_FN.format(
table=self.table_name,
))
schema_editor.execute(_BEFORE_TRIGGER_PATH_AND_SORT.format(
table=self.table_name, name_col=self.name_column,
))
schema_editor.execute(_AFTER_TRIGGER_PATH_AND_SORT.format(
table=self.table_name, name_col=self.name_column,
))
else:
schema_editor.execute(_COMPUTE_PATH_ONLY_FN.format(table=self.table_name))
schema_editor.execute(_CASCADE_PATH_ONLY_FN.format(table=self.table_name))
schema_editor.execute(_BEFORE_TRIGGER_PATH_ONLY.format(table=self.table_name))
schema_editor.execute(_AFTER_TRIGGER_PATH_ONLY.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}"

View File

@ -0,0 +1,115 @@
"""
Reusable SQL builders for migrating a django-mptt tree to a PostgreSQL ltree
`path` (and optional `sort_path`) column.
NetBox's core hierarchical models moved from django-mptt to ltree in v4.6. The
per-table data backfill that migration performs is identical in shape for every
tree, so the SQL is centralized here rather than copied into each app's
migration. This also gives plugin maintainers a supported path for migrating
their own MPTT models to `netbox.models.ltree.LtreeModel`; from a data migration:
from django.contrib.postgres.operations import CreateExtension
from django.db import migrations
from utilities.ltree import InstallLtreeTriggers
from utilities.mptt_to_ltree import assert_paths_populated_sql, populate_paths_sql
operations = [
CreateExtension('ltree'),
# ... AddField('path', nullable), [AddField('sort_path')], InstallLtreeTriggers(...) ...
migrations.RunSQL(
populate_paths_sql('myplugin_mymodel', sort_path=True),
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
assert_paths_populated_sql('myplugin_mymodel'),
reverse_sql=migrations.RunSQL.noop,
),
# ... AlterField('path' -> NOT NULL) ...
]
The values produced here must stay byte-identical to what the runtime triggers
in `utilities.ltree` maintain: each path label is the row PK zero-padded to
`_PATH_LABEL_WIDTH` chars, and `sort_path` is the chr(9) (TAB) separated chain of
ancestor `name` values. Keep the two modules in sync if either changes.
"""
__all__ = (
'assert_paths_populated_sql',
'populate_paths_sql',
)
# Width to which each PK is zero-padded when used as an ltree label. Must match
# the lpad() width used by the trigger functions in utilities.ltree (19 = max
# bigint digit width) so that backfilled paths and trigger-maintained paths sort
# and compare identically.
_PATH_LABEL_WIDTH = 19
def populate_paths_sql(table, *, sort_path=False):
"""
Return SQL that backfills `path` (and `sort_path` when `sort_path=True`) for
every existing row in `table`, walking the tree from its roots
(parent_id IS NULL) downward via a single recursive CTE.
`path` is the chain of PK labels, each zero-padded to `_PATH_LABEL_WIDTH`
chars. `sort_path` is the chr(9) (TAB) separated chain of ancestor `name`
values, matching the `order_insertion_by=('name',)` semantics the triggers
maintain at runtime.
!!! warning
The UPDATE takes a row-exclusive lock on the entire table for the
duration of the statement. On large tables this can block writes for
minutes plan a maintenance window accordingly.
"""
if sort_path:
return f"""
WITH RECURSIVE t(id, parent_id, path, sort_path) AS (
SELECT id, parent_id,
lpad(id::text, {_PATH_LABEL_WIDTH}, '0')::ltree,
name::text
FROM "{table}" WHERE parent_id IS NULL
UNION ALL
SELECT r.id, r.parent_id,
t.path || lpad(r.id::text, {_PATH_LABEL_WIDTH}, '0')::ltree,
t.sort_path || chr(9) || r.name
FROM "{table}" r JOIN t ON r.parent_id = t.id
)
UPDATE "{table}" SET path = t.path, sort_path = t.sort_path
FROM t WHERE "{table}".id = t.id;
"""
return f"""
WITH RECURSIVE t(id, parent_id, path) AS (
SELECT id, parent_id, lpad(id::text, {_PATH_LABEL_WIDTH}, '0')::ltree
FROM "{table}" WHERE parent_id IS NULL
UNION ALL
SELECT r.id, r.parent_id, t.path || lpad(r.id::text, {_PATH_LABEL_WIDTH}, '0')::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;
"""
def assert_paths_populated_sql(table):
"""
Return SQL that raises if any row in `table` still has a NULL `path` after
`populate_paths_sql()` runs.
The recursive CTE only reaches rows whose ancestry chains back to a
`parent_id IS NULL` root, so any row left with a NULL path points (directly
or transitively) at an orphan or cyclic parent_id. Catch that here, naming
the table and row count, rather than letting the subsequent
`AlterField(path -> NOT NULL)` abort opaquely inside ALTER COLUMN.
"""
return f"""
DO $$
DECLARE missing bigint;
BEGIN
SELECT count(*) INTO missing FROM "{table}" WHERE path IS NULL;
IF missing > 0 THEN
RAISE EXCEPTION
'ltree backfill left % rows in "{table}" with NULL path; '
'likely orphan parent_id references — resolve before re-running '
'this migration', missing;
END IF;
END $$;
"""

View File

@ -1,8 +1,6 @@
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.db.models.functions import Coalesce
from utilities.mptt import TreeManager
__all__ = (
'count_related',
'dict_to_filter_params',
@ -59,14 +57,40 @@ def dict_to_filter_params(d, prefix=''):
return params
# TODO: Remove in NetBox v5.0. MPTT support is retained only for plugins that have
# not yet migrated their tree models to netbox.models.ltree.LtreeModel; NetBox core
# no longer uses django-mptt. When MPTT support is dropped, delete this helper and
# its call in reapply_model_ordering().
def _is_mptt_model(model) -> bool:
"""
Whether `model` is backed by django-mptt (deprecated). MPTT applies its own
tree ordering via the TreeManager, so such querysets must NOT have plain
model-level ordering reapplied after .annotate().
"""
from mptt.managers import TreeManager
return any(isinstance(manager, TreeManager) for manager in model._meta.local_managers)
def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
"""
Reapply model-level ordering in case it has been lost through .annotate().
https://code.djangoproject.com/ticket/32811
"""
# MPTT-based models are exempt from this; use caution when annotating querysets of these models
if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
# Models ordered by a trigger-maintained ltree column (`sort_path`/`path`) are
# exempt. Key the check on the ordering itself, NOT on LtreeManager presence:
# InventoryItem/InventoryItemTemplate use an LtreeManager only for path
# maintenance but order by a regular column (name), so they DO need their
# ordering reapplied after .annotate() strips it (Django #32811).
ordering = queryset.model._meta.ordering or ()
if any(isinstance(f, str) and f.lstrip('-') in ('sort_path', 'path') for f in ordering):
return queryset
# TODO: Remove in NetBox v5.0 (see _is_mptt_model). Plugins may still use MPTT
# via the generic bulk views, so keep exempting MPTT-based models for now.
if _is_mptt_model(queryset.model):
return queryset
if queryset.ordered:
return queryset

View File

@ -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
)
)

View File

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

View File

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

View File

@ -0,0 +1,911 @@
"""Tests for the ltree-based hierarchical model infrastructure."""
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test import TestCase
from core.models import ObjectChange
from dcim.models import Region, Site
from tenancy.models import Contact, ContactGroup
def _path(*pks):
"""Construct the expected ltree path value from a sequence of PKs.
Mirrors the trigger's zero-padded label scheme (19 chars per label).
"""
return '.'.join(str(pk).zfill(19) for pk in pks)
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, _path(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, _path(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, _path(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, _path(c.pk))
self.assertEqual(g.path, _path(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, _path(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, _path(r2.pk, c.pk))
self.assertEqual(g.path, _path(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 AdvisoryLockScopeTests(TestCase):
"""
Pin where the BEFORE trigger takes its per-tree advisory lock.
A new root (INSERT with parent_id IS NULL) is a race-free singleton tree and
must take NO lock, so a bulk import of many top-level objects cannot exhaust
the shared lock table. Every other write (child insert, reparent-to-root)
must still lock per tree the companion assertions keep the optimization from
silently over-broadening to cases that need serialization.
`pg_locks` is cluster-wide and advisory *xact* locks are held until the
TestCase transaction ends, so each test measures the DELTA in this backend's
advisory-lock count across a single operation rather than an absolute count.
"""
@staticmethod
def _advisory_lock_count():
with connection.cursor() as cursor:
cursor.execute(
"SELECT count(*) FROM pg_locks "
"WHERE locktype = 'advisory' AND pid = pg_backend_pid()"
)
return cursor.fetchone()[0]
def test_root_insert_takes_no_lock(self):
before = self._advisory_lock_count()
Region.objects.create(name='Root', slug='root-lock')
self.assertEqual(self._advisory_lock_count() - before, 0)
def test_child_insert_takes_one_lock(self):
root = Region.objects.create(name='Root', slug='root-lock2')
before = self._advisory_lock_count()
Region.objects.create(parent=root, name='Child', slug='child-lock2')
self.assertEqual(self._advisory_lock_count() - before, 1)
def test_reparent_to_root_takes_lock(self):
root = Region.objects.create(name='Root', slug='root-lock3')
child = Region.objects.create(parent=root, name='Child', slug='child-lock3')
before = self._advisory_lock_count()
child.parent = None
child.save()
# An existing row promoted to root still has a real subtree to rewrite, so
# it must lock (its own new root key, plus the old tree's root for the move).
self.assertGreaterEqual(self._advisory_lock_count() - before, 1)
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_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'])
def test_get_children(self):
children = sorted(self.root.get_children().values_list('name', flat=True))
self.assertEqual(children, ['Mid'])
class CycleValidationTests(TestCase):
"""clean() and save() must refuse to assign self or 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()
def test_cycle_raises_on_save_without_clean(self):
# The save()-level guard mirrors django-mptt's InvalidMove: a cyclic move
# is rejected even when full_clean() is bypassed (scripts, bulk callers).
from django.core.exceptions import ValidationError
a = Region.objects.create(name='A', slug='a-cyc2')
b = Region.objects.create(parent=a, name='B', slug='b-cyc2')
Region.objects.create(parent=b, name='C', slug='c-cyc2')
a.parent = b
with self.assertRaises(ValidationError):
a.save()
# The rejected move must leave the tree unchanged.
a.refresh_from_db()
self.assertIsNone(a.parent_id)
def test_self_parent_raises_on_save(self):
from django.core.exceptions import ValidationError
a = Region.objects.create(name='A', slug='a-self')
a.parent = a
with self.assertRaises(ValidationError):
a.save()
def test_move_to_unrelated_parent_is_allowed(self):
# A legitimate move (target is neither self nor a descendant) must succeed.
a = Region.objects.create(name='A', slug='a-ok')
b = Region.objects.create(name='B', slug='b-ok')
child = Region.objects.create(parent=a, name='Child', slug='child-ok')
child.parent = b
child.save()
child.refresh_from_db()
self.assertEqual(child.parent_id, b.pk)
self.assertEqual(child.path, _path(b.pk, child.pk))
class SortPathTests(TestCase):
"""
Verify that sort_path produces tree-flatten output with siblings in name
order, mirroring MPTT's `order_insertion_by=('name',)` behavior.
"""
def test_siblings_in_name_order_regardless_of_insertion_order(self):
# Create siblings out of name order
Region.objects.create(name='Zebra', slug='zebra-sp')
Region.objects.create(name='Aardvark', slug='aardvark-sp')
buffalo = Region.objects.create(name='Buffalo', slug='buffalo-sp')
# Children of Buffalo also out of order
Region.objects.create(parent=buffalo, name='Zoo', slug='b-zoo-sp')
Region.objects.create(parent=buffalo, name='Apex', slug='b-apex-sp')
ordered = list(
Region.objects.filter(slug__endswith='-sp')
.order_by('sort_path')
.values_list('name', flat=True)
)
# Tree-flatten with siblings in name order:
# Aardvark, Buffalo (parent), Apex (child), Zoo (child), Zebra
self.assertEqual(ordered, ['Aardvark', 'Buffalo', 'Apex', 'Zoo', 'Zebra'])
def test_default_ordering_is_sort_path(self):
"""Region.objects.all() uses sort_path-based ordering by default."""
Region.objects.create(name='B', slug='b-default')
Region.objects.create(name='A', slug='a-default')
names = list(
Region.objects.filter(slug__endswith='-default').values_list('name', flat=True)
)
self.assertEqual(names, ['A', 'B'])
def test_get_descendants_returns_siblings_in_name_order(self):
"""
For models with a sort_path column, get_descendants() must return
descendants with siblings in name order (matching MPTT's
order_insertion_by behavior), not insertion/PK order.
"""
root = Region.objects.create(name='Root', slug='root-gdord')
# Insert siblings out of name order
Region.objects.create(parent=root, name='Zebra', slug='z-gdord')
Region.objects.create(parent=root, name='Aardvark', slug='a-gdord')
Region.objects.create(parent=root, name='Buffalo', slug='b-gdord')
names = list(root.get_descendants().values_list('name', flat=True))
self.assertEqual(names, ['Aardvark', 'Buffalo', 'Zebra'])
def test_get_children_returns_in_name_order(self):
root = Region.objects.create(name='Root', slug='root-gcord')
Region.objects.create(parent=root, name='Zebra', slug='z-gcord')
Region.objects.create(parent=root, name='Aardvark', slug='a-gcord')
names = list(root.get_children().values_list('name', flat=True))
self.assertEqual(names, ['Aardvark', 'Zebra'])
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)
def test_cumulative_m2m_count(self):
"""
Cumulative count over an M2M relation walks the subtree via
path <@, joining the through table by the correct columns.
Regression: previously the JOINs swapped m2m_column_name() and
m2m_reverse_name(), producing wrong counts on M2M relations.
"""
root = ContactGroup.objects.create(name='Root', slug='root-m2m')
child = ContactGroup.objects.create(parent=root, name='Child', slug='child-m2m')
leaf = ContactGroup.objects.create(parent=child, name='Leaf', slug='leaf-m2m')
# 3 contacts spread across the subtree (one per node)
c_root = Contact.objects.create(name='CR')
c_child = Contact.objects.create(name='CC')
c_leaf = Contact.objects.create(name='CL')
c_root.groups.add(root)
c_child.groups.add(child)
c_leaf.groups.add(leaf)
qs = ContactGroup.objects.add_related_count(
ContactGroup.objects.filter(slug__endswith='-m2m'),
Contact, 'groups', 'contact_count', cumulative=True,
)
counts = {g.name: g.contact_count for g in qs}
self.assertEqual(counts['Root'], 3)
self.assertEqual(counts['Child'], 2)
self.assertEqual(counts['Leaf'], 1)
def test_noncumulative_fk_count(self):
"""Non-cumulative FK count includes only rows pointing directly at the node."""
root = Region.objects.create(name='R', slug='r-ncfk')
child = Region.objects.create(parent=root, name='C', slug='c-ncfk')
Site.objects.create(name='S1', slug='s1-ncfk', region=child)
Site.objects.create(name='S2', slug='s2-ncfk', region=root)
qs = Region.objects.add_related_count(
Region.objects.filter(slug__endswith='-ncfk'),
Site, 'region', 'site_count', cumulative=False,
)
counts = {r.name: r.site_count for r in qs}
# Each node counts only its own directly-assigned sites (no subtree rollup).
self.assertEqual(counts['R'], 1)
self.assertEqual(counts['C'], 1)
def test_noncumulative_m2m_count(self):
"""Non-cumulative M2M count includes only directly-assigned rows, not the subtree."""
root = ContactGroup.objects.create(name='Root', slug='root-ncm2m')
child = ContactGroup.objects.create(parent=root, name='Child', slug='child-ncm2m')
c_root = Contact.objects.create(name='CR-nc')
c_child = Contact.objects.create(name='CC-nc')
c_root.groups.add(root)
c_child.groups.add(child)
qs = ContactGroup.objects.add_related_count(
ContactGroup.objects.filter(slug__endswith='-ncm2m'),
Contact, 'groups', 'contact_count', cumulative=False,
)
counts = {g.name: g.contact_count for g in qs}
self.assertEqual(counts['Root'], 1)
self.assertEqual(counts['Child'], 1)
class SaveUpdateFieldsTests(TestCase):
"""
Regression: when save(update_fields=...) excludes parent, _loaded_parent_id
must not advance, otherwise a subsequent full save() will mis-detect the
parent change as already-applied and leave path stale in memory.
"""
def test_partial_save_then_full_save_refreshes_path(self):
r1 = Region.objects.create(name='R1', slug='r1-uf')
r2 = Region.objects.create(name='R2', slug='r2-uf')
obj = Region.objects.create(name='Obj', slug='obj-uf')
original_path = obj.path
# Reparent in memory but persist a different field only:
obj.parent = r1
obj.name = 'Obj-renamed'
obj.save(update_fields=['name'])
# DB parent_id is still NULL — confirm:
db_parent = Region.objects.values_list('parent_id', flat=True).get(pk=obj.pk)
self.assertIsNone(db_parent)
self.assertEqual(
Region.objects.values_list('path', flat=True).get(pk=obj.pk),
original_path,
)
# Now a full save persists the new parent — path must refresh:
obj.parent = r2
obj.save()
db_path = Region.objects.values_list('path', flat=True).get(pk=obj.pk)
self.assertEqual(db_path, _path(r2.pk, obj.pk))
self.assertEqual(obj.path, db_path, "in-memory path is stale after full save")
class BulkCreateOrderingTests(TestCase):
"""
The BEFORE INSERT trigger looks up parent.path via subquery per row.
In bulk_create the trigger fires per row in list order, so a parent
placed in the same batch must precede its children.
"""
def test_parent_before_child_in_same_batch(self):
root = Region.objects.create(name='R', slug='r-bcord')
# Parent BEFORE child in the list — both get correct paths
parent_pending = Region(parent=root, name='Mid', slug='mid-bcord')
child_pending = Region(parent=parent_pending, name='Leaf', slug='leaf-bcord')
# parent_pending isn't yet saved, so child_pending.parent_id is None;
# set the parent reference after the parent is saved.
Region.objects.bulk_create([parent_pending])
parent_pending.refresh_from_db()
child_pending.parent = parent_pending
Region.objects.bulk_create([child_pending])
child_pending.refresh_from_db()
self.assertEqual(child_pending.path, _path(root.pk, parent_pending.pk, child_pending.pk))
def test_bulk_create_rejects_child_before_parent_in_same_batch(self):
"""The guard refuses misordered batches instead of writing bad paths."""
unsaved_parent = Region(name='P', slug='p-bcrej')
unsaved_child = Region(parent=unsaved_parent, name='C', slug='c-bcrej')
with self.assertRaises(ValueError) as ctx:
Region.objects.bulk_create([unsaved_child, unsaved_parent])
self.assertIn('unsaved parent', str(ctx.exception))
def test_bulk_create_rejects_unsaved_parent_earlier_in_batch(self):
"""
An unsaved parent placed earlier in the batch must also be rejected:
Django binds VALUES from the child's parent_id BEFORE the parent's
RETURNING-assigned pk lands, so the child would be inserted with
parent_id=NULL and silently stored as a root.
"""
unsaved_parent = Region(name='P', slug='p-bcearly')
unsaved_child = Region(parent=unsaved_parent, name='C', slug='c-bcearly')
with self.assertRaises(ValueError) as ctx:
Region.objects.bulk_create([unsaved_parent, unsaved_child])
self.assertIn('unsaved parent', str(ctx.exception))
# Nothing should have been written.
self.assertFalse(Region.objects.filter(slug__in=('p-bcearly', 'c-bcearly')).exists())
def test_bulk_create_rejects_unsaved_parent_not_in_batch(self):
"""
An unsaved parent that isn't in the batch must be rejected with a clear
ValueError; otherwise the child's parent_id is None at INSERT time and
the BEFORE trigger silently stores the row as a root.
"""
external_parent = Region(name='X', slug='x-bcok') # not in the batch
with self.assertRaises(ValueError) as ctx:
Region.objects.bulk_create([Region(parent=external_parent, name='Y', slug='y-bcok')])
self.assertIn('unsaved parent', str(ctx.exception))
# Nothing should have been written.
self.assertFalse(Region.objects.filter(slug='y-bcok').exists())
class TreeNodeFilterTests(TestCase):
"""
Regression: the FK branch of TreeNodeFilter.filter() previously destructured
q_filter.children as (str, value) tuples, which crashed for compound Q
match types (DESCENDANTS, ANCESTORS, SIBLINGS, SELF_AND_DESCENDANTS). The
FK branch must resolve via __in like the M2M / M2O branches.
"""
@classmethod
def setUpTestData(cls):
from tenancy.models import Tenant, TenantGroup
cls.Tenant = Tenant
cls.TenantGroup = TenantGroup
cls.root = TenantGroup.objects.create(name='Root', slug='root-tnf')
cls.mid = TenantGroup.objects.create(parent=cls.root, name='Mid', slug='mid-tnf')
cls.leaf = TenantGroup.objects.create(parent=cls.mid, name='Leaf', slug='leaf-tnf')
cls.sibling = TenantGroup.objects.create(parent=cls.root, name='Sibling', slug='sibling-tnf')
cls.t_root = Tenant.objects.create(name='TRoot', slug='troot-tnf', group=cls.root)
cls.t_mid = Tenant.objects.create(name='TMid', slug='tmid-tnf', group=cls.mid)
cls.t_leaf = Tenant.objects.create(name='TLeaf', slug='tleaf-tnf', group=cls.leaf)
cls.t_sibling = Tenant.objects.create(name='TSib', slug='tsib-tnf', group=cls.sibling)
def _filter(self, match_type):
from netbox.graphql.filter_lookups import TreeNodeFilter, TreeNodeMatch
tnf = TreeNodeFilter(id=self.mid.pk, match_type=getattr(TreeNodeMatch, match_type))
# `filter` is decorated by @strawberry_django.filter_field; its wrapper
# asserts info is not None. The undecorated body is on `_unbound_wrapped_func`.
inner = TreeNodeFilter.filter._unbound_wrapped_func
qs, q = inner(tnf, info=None, queryset=self.Tenant.objects.all(), prefix='group__')
return list(qs.filter(q).values_list('name', flat=True))
def test_descendants_strict(self):
# DESCENDANTS of `mid` = leaf only (Mid itself excluded)
names = self._filter('DESCENDANTS')
self.assertEqual(sorted(names), ['TLeaf'])
def test_self_and_descendants(self):
names = self._filter('SELF_AND_DESCENDANTS')
self.assertEqual(sorted(names), ['TLeaf', 'TMid'])
def test_ancestors(self):
names = self._filter('ANCESTORS')
# Ancestors of Mid = Root only (Mid itself excluded)
self.assertEqual(sorted(names), ['TRoot'])
def test_siblings(self):
# Siblings of Mid (within Root) = Sibling
names = self._filter('SIBLINGS')
self.assertEqual(sorted(names), ['TSib'])
class DescendantLookupSemanticsTests(TestCase):
"""
path__descendant is strict (path <@ rhs AND path != rhs); the inclusive
form is path__descendant_or_equal. Previously both were inclusive.
"""
def test_strict_descendant_excludes_self(self):
root = Region.objects.create(name='Root', slug='root-dls')
Region.objects.create(parent=root, name='Kid', slug='kid-dls')
strict = list(
Region.objects.filter(path__descendant=root.path)
.values_list('name', flat=True)
)
self.assertEqual(sorted(strict), ['Kid'])
inclusive = list(
Region.objects.filter(path__descendant_or_equal=root.path)
.values_list('name', flat=True)
)
self.assertEqual(sorted(inclusive), ['Kid', 'Root'])
class AncestorLookupSemanticsTests(TestCase):
"""
path__ancestor is strict (path @> rhs AND path != rhs); the inclusive form
is path__ancestor_or_equal.
"""
def test_strict_ancestor_excludes_self(self):
root = Region.objects.create(name='Root', slug='root-als')
kid = Region.objects.create(parent=root, name='Kid', slug='kid-als')
strict = list(
Region.objects.filter(path__ancestor=kid.path)
.values_list('name', flat=True)
)
self.assertEqual(sorted(strict), ['Root'])
inclusive = list(
Region.objects.filter(path__ancestor_or_equal=kid.path)
.values_list('name', flat=True)
)
self.assertEqual(sorted(inclusive), ['Kid', 'Root'])
class RenameCascadesSortPathTests(TestCase):
"""
Renaming a node updates its own sort_path AND cascades into descendants'
sort_paths via the AFTER trigger. (Diverges from MPTT order_insertion_by,
which leaves descendants stale until manual rebuild.)
"""
def test_rename_cascades_into_descendants(self):
parent = Region.objects.create(name='Bravo', slug='bravo-rcsp')
mid = Region.objects.create(parent=parent, name='Mid', slug='mid-rcsp')
leaf = Region.objects.create(parent=mid, name='Leaf', slug='leaf-rcsp')
parent.name = 'Zulu'
parent.save()
parent.refresh_from_db()
mid.refresh_from_db()
leaf.refresh_from_db()
self.assertEqual(parent.sort_path, 'Zulu')
self.assertEqual(mid.sort_path, f'Zulu{chr(9)}Mid')
self.assertEqual(leaf.sort_path, f'Zulu{chr(9)}Mid{chr(9)}Leaf')
# Paths unchanged — only sort_path moved.
self.assertEqual(mid.path, _path(parent.pk, mid.pk))
self.assertEqual(leaf.path, _path(parent.pk, mid.pk, leaf.pk))
def test_rename_does_not_affect_unrelated_subtree(self):
# Two roots; renaming one must not touch the other's sort_path.
a = Region.objects.create(name='AA', slug='aa-iso')
Region.objects.create(parent=a, name='AKid', slug='akid-iso')
b = Region.objects.create(name='BB', slug='bb-iso')
b_kid = Region.objects.create(parent=b, name='BKid', slug='bkid-iso')
a.name = 'AAren'
a.save()
b_kid.refresh_from_db()
self.assertEqual(b_kid.sort_path, f'BB{chr(9)}BKid')
class RebuildSortPathsTests(TestCase):
"""rebuild_sort_paths() is still available for repair after raw SQL writes."""
def test_rebuild_after_raw_update(self):
parent = Region.objects.create(name='Bravo', slug='bravo-rsp')
mid = Region.objects.create(parent=parent, name='Mid', slug='mid-rsp')
# Raw .update() with only the name column bypasses the BEFORE trigger
# (its column list is parent_id + name, but the trigger is keyed on
# name in the SET clause). Actually update() on `name` DOES fire the
# trigger now — so to simulate a bypass we corrupt sort_path directly.
Region.objects.filter(pk=parent.pk).update(sort_path='garbage')
Region.objects.filter(pk=mid.pk).update(sort_path='also-garbage')
Region.rebuild_sort_paths()
parent.refresh_from_db()
mid.refresh_from_db()
self.assertEqual(parent.sort_path, 'Bravo')
self.assertEqual(mid.sort_path, f'Bravo{chr(9)}Mid')
def test_raises_without_sort_path(self):
# InventoryItem uses LtreeModel but doesn't have a sort_path column.
from dcim.models import InventoryItem
with self.assertRaises(NotImplementedError):
InventoryItem.rebuild_sort_paths()
class SortPathRefreshTests(TestCase):
"""
save() must refresh the in-memory `sort_path` (not just `path`) after insert
and reparent, so callers (notably change logging) never snapshot a stale value.
"""
def test_create_refreshes_sort_path(self):
root = Region.objects.create(name='Root', slug='root-spr')
child = Region.objects.create(name='Kid', slug='kid-spr', parent=root)
db_sort_path = Region.objects.values_list('sort_path', flat=True).get(pk=child.pk)
self.assertEqual(child.sort_path, db_sort_path)
self.assertEqual(child.sort_path, f'Root{chr(9)}Kid')
def test_reparent_refreshes_sort_path(self):
a = Region.objects.create(name='Alpha', slug='alpha-spr')
b = Region.objects.create(name='Bravo', slug='bravo-spr')
child = Region.objects.create(name='Kid', slug='kid2-spr', parent=a)
child.parent = b
child.save()
db_sort_path = Region.objects.values_list('sort_path', flat=True).get(pk=child.pk)
self.assertEqual(child.sort_path, db_sort_path)
self.assertEqual(child.sort_path, f'Bravo{chr(9)}Kid')
def test_rename_refreshes_sort_path(self):
# The trigger rewrites sort_path on a name change; the in-memory instance
# must reflect it without a manual refresh_from_db().
root = Region.objects.create(name='Root', slug='root-rn')
root.name = 'Renamed'
root.save()
db_sort_path = Region.objects.values_list('sort_path', flat=True).get(pk=root.pk)
self.assertEqual(root.sort_path, db_sort_path)
self.assertEqual(root.sort_path, 'Renamed')
class ReapplyModelOrderingTests(TestCase):
"""Ltree-backed models must be exempt from reapply_model_ordering()."""
def test_ltree_model_is_exempt(self):
from utilities.query import reapply_model_ordering
# Clear ordering so a non-exempt model would be re-ordered by Meta.ordering.
qs = Region.objects.all().order_by()
result = reapply_model_ordering(qs)
# The LtreeManager is inherited from the abstract base (not in
# local_managers), so the exemption must still apply and return qs as-is.
self.assertIs(result, qs)
def test_mptt_model_detection(self):
# MPTT support is retained for plugins (NestedGroupModel stays MPTT-backed);
# reapply_model_ordering() must keep exempting such models. See _is_mptt_model.
# TODO: Remove in NetBox v5.0 alongside MPTT support.
from netbox.models import NestedGroupModel
from utilities.query import _is_mptt_model
self.assertTrue(_is_mptt_model(NestedGroupModel))
# An ltree-backed model must NOT be misdetected as MPTT.
self.assertFalse(_is_mptt_model(Region))
class AddRelatedCountErrorTests(TestCase):
"""
add_related_count() must not raise at queryset-build time for unresolvable
rel_field many call sites bind the annotation as a class attribute at
module import. The annotation is still attached using the Django default
column naming, and any error surfaces at evaluation time.
"""
def test_unknown_field_does_not_raise_at_build(self):
qs = Region.objects.add_related_count(
Region.objects.all(), Region, 'not_a_field', 'bogus_count', cumulative=True
)
self.assertIn('bogus_count', qs.query.annotations)
class ChangeLogExclusionTests(TestCase):
"""
Trigger-maintained columns (`path`, `sort_path`) must be excluded from change
log diffs, and the postchange snapshot must capture the refreshed values.
"""
def test_sort_path_excluded_from_diff(self):
oc = ObjectChange()
oc.changed_object_type = ContentType.objects.get_for_model(Region)
self.assertIn('path', oc.diff_exclude_fields)
self.assertIn('sort_path', oc.diff_exclude_fields)
def test_reparent_postchange_snapshot_matches_db(self):
a = Region.objects.create(name='Alpha', slug='alpha-cl')
b = Region.objects.create(name='Bravo', slug='bravo-cl')
child = Region.objects.create(name='Kid', slug='kid-cl', parent=a)
# Reload so the prechange snapshot reflects the persisted state.
child = Region.objects.get(pk=child.pk)
child.snapshot()
child.parent = b
child.save()
oc = child.to_objectchange('update')
db = Region.objects.values('path', 'sort_path').get(pk=child.pk)
self.assertEqual(oc.postchange_data['path'], db['path'])
self.assertEqual(oc.postchange_data['sort_path'], db['sort_path'])
# path/sort_path are excluded, so they must not surface in the cleaned diff data.
self.assertNotIn('sort_path', oc.postchange_data_clean)
self.assertNotIn('path', oc.postchange_data_clean)
class MPTTChangeLogExclusionTests(TestCase):
"""
ObjectChange.diff_exclude_fields must hide MPTT bookkeeping columns
(lft/rght/tree_id/level) for plugin models still using the deprecated
MPTT-backed NestedGroupModel, in addition to ltree's path/sort_path.
"""
def test_diff_exclude_fields_for_mptt_subclass(self):
from unittest.mock import MagicMock
from mptt.models import MPTTModel
class _FakeMPTT(MPTTModel):
class Meta:
abstract = True
app_label = 'tests'
oc = ObjectChange()
fake_ct = MagicMock()
fake_ct.model_class.return_value = _FakeMPTT
# Prime the FK descriptor's cache so accessing changed_object_type
# returns our mock without hitting the DB or invoking type checks.
ObjectChange._meta.get_field('changed_object_type').set_cached_value(oc, fake_ct)
excluded = oc.diff_exclude_fields
self.assertIn('lft', excluded)
self.assertIn('rght', excluded)
self.assertIn('tree_id', excluded)
self.assertIn('level', excluded)
class AddRelatedCountResilienceTests(TestCase):
"""
add_related_count() must not raise FieldDoesNotExist at queryset-build
time so that view modules (which bind it as a class attribute) can be
imported even if a referenced field has been renamed.
"""
def test_unknown_field_does_not_raise(self):
# Bare manager call equivalent to a view class body using a stale name.
qs = Region.objects.add_related_count(
Region.objects.all(),
Region, # any model; the field name is what matters
'this_field_does_not_exist',
'noop_count',
cumulative=True,
)
# The annotation was attached; evaluating it would fail at the DB
# (column doesn't exist) but importing the view module must succeed.
self.assertIn('noop_count', qs.query.annotations)
class CascadeTriggerScopeTests(TestCase):
"""
The AFTER cascade trigger fires on parent_id or name changes. A rename
leaves `path` untouched but pushes the new sort_path into descendants.
"""
def test_rename_preserves_descendant_path_but_updates_sort_path(self):
parent = Region.objects.create(name='Bravo', slug='bravo-ct')
child = Region.objects.create(parent=parent, name='Mid', slug='mid-ct')
original_child_path = child.path
parent.name = 'Zulu'
parent.save()
child.refresh_from_db()
# Path is unaffected by a rename; sort_path follows the new name.
self.assertEqual(child.path, original_child_path)
self.assertNotIn('Bravo', child.sort_path)
self.assertIn('Zulu', child.sort_path)
class CycleGuardWithEmptyPathTests(TestCase):
"""
_parent_creates_cycle must catch self-as-parent even when self.path is
empty or deferred otherwise an instance constructed without a loaded
path can pass the Python guard and corrupt the tree.
"""
def test_self_parent_rejected_when_path_is_empty(self):
from django.core.exceptions import ValidationError
a = Region.objects.create(name='A', slug='a-empty-cyc')
# Simulate a caller (script, plugin) that holds an instance whose `path`
# attribute was never loaded — e.g. via .only('id', 'parent_id').
# Empty-string path is the LtreeField default, so this mirrors what a
# deferred-or-unset path looks like at the Python layer.
a.path = ''
a.parent_id = a.pk
with self.assertRaises(ValidationError):
a.save()
class TriggerCycleGuardTests(TestCase):
"""
The BEFORE INSERT/UPDATE trigger must reject a parent_id assignment that
would form a cycle. The Python save()-time guard already catches this for
ordinary save(); the trigger backstops QuerySet.update / bulk_update /
raw UPDATEs that bypass save().
"""
def test_queryset_update_to_self_parent_blocked_by_trigger(self):
from django.db import IntegrityError, transaction
a = Region.objects.create(name='A', slug='a-tgcyc')
# IntegrityError leaves Django's transaction marked-for-rollback; wrap
# the failing UPDATE in a savepoint so the outer test transaction can
# continue to issue queries (refresh_from_db).
with self.assertRaises(IntegrityError) as ctx:
with transaction.atomic():
Region.objects.filter(pk=a.pk).update(parent_id=a.pk)
self.assertIn('cycle detected', str(ctx.exception))
a.refresh_from_db()
self.assertIsNone(a.parent_id)
def test_queryset_update_to_descendant_blocked_by_trigger(self):
from django.db import IntegrityError, transaction
a = Region.objects.create(name='A', slug='a-tgcyc2')
b = Region.objects.create(parent=a, name='B', slug='b-tgcyc2')
# Try to reparent A under B (B is A's descendant) via raw UPDATE.
with self.assertRaises(IntegrityError) as ctx:
with transaction.atomic():
Region.objects.filter(pk=a.pk).update(parent_id=b.pk)
self.assertIn('cycle detected', str(ctx.exception))
a.refresh_from_db()
self.assertIsNone(a.parent_id)
def test_legitimate_reparent_via_update_still_works(self):
a = Region.objects.create(name='A', slug='a-tgok')
b = Region.objects.create(name='B', slug='b-tgok')
child = Region.objects.create(parent=a, name='C', slug='c-tgok')
Region.objects.filter(pk=child.pk).update(parent_id=b.pk)
child.refresh_from_db()
self.assertEqual(child.parent_id, b.pk)
self.assertEqual(child.path, _path(b.pk, child.pk))
def test_queryset_update_mid_tree_to_own_descendant_blocked(self):
"""
Reparenting a non-root node under one of its own (non-immediate)
descendants must raise. The earlier `lpad(NEW.id) @> parent_path`
check only fired when NEW was the root of parent_path, missing
mid-tree cycles entirely.
"""
from django.db import IntegrityError, transaction
a = Region.objects.create(name='A', slug='a-midcyc')
b = Region.objects.create(parent=a, name='B', slug='b-midcyc')
c = Region.objects.create(parent=b, name='C', slug='c-midcyc')
# Try to make B a child of C — B is mid-tree (path A.B), parent_path is A.B.C.
with self.assertRaises(IntegrityError) as ctx:
with transaction.atomic():
Region.objects.filter(pk=b.pk).update(parent_id=c.pk)
self.assertIn('cycle detected', str(ctx.exception))
b.refresh_from_db()
self.assertEqual(b.parent_id, a.pk)
def test_queryset_update_mid_tree_self_loop_blocked(self):
"""A non-root node assigning itself as parent must also raise."""
from django.db import IntegrityError, transaction
a = Region.objects.create(name='A', slug='a-midself')
b = Region.objects.create(parent=a, name='B', slug='b-midself')
with self.assertRaises(IntegrityError) as ctx:
with transaction.atomic():
Region.objects.filter(pk=b.pk).update(parent_id=b.pk)
self.assertIn('cycle detected', str(ctx.exception))
b.refresh_from_db()
self.assertEqual(b.parent_id, a.pk)
class NaturalSortSortPathTests(TestCase):
"""
Sort-path separator must produce correct tree-flatten ordering under the
`natural_sort` (ICU `und-u-kn-true`) collation used by ModuleBay,
TenantGroup, and WirelessLANGroup. chr(1) was variable-ignorable under
that collation and would interleave children with unrelated roots;
chr(9) (TAB) is treated non-variably and orders deterministically.
"""
def test_chr9_separator_collates_below_letters_under_natural_sort(self):
# Direct collation probe — independent of any model schema.
with connection.cursor() as cur:
cur.execute("""
SELECT
('A' || chr(9) || 'Z') COLLATE "natural_sort"
< 'AA' COLLATE "natural_sort"
""")
self.assertTrue(cur.fetchone()[0])
def test_tree_flatten_ordering_under_natural_sort(self):
# TenantGroup.sort_path uses natural_sort collation; build a small tree
# where chr(1) would have produced wrong sibling ordering. TenantGroup
# names are globally unique, so each row gets a distinct name. Under
# the old chr(1) separator the child's sort_path was variable-ignorable
# and collated AFTER the unrelated root 'nsP1'; chr(9) sorts strictly
# below digits/letters and keeps children clustered under their parent.
from tenancy.models import TenantGroup
parent = TenantGroup.objects.create(name='nsP', slug='nsp-ns')
TenantGroup.objects.create(parent=parent, name='nsPchild', slug='nspc-ns')
TenantGroup.objects.create(name='nsP1', slug='nsp1-ns') # unrelated root
names = list(
TenantGroup.objects.filter(slug__endswith='-ns')
.order_by('sort_path')
.values_list('name', flat=True)
)
# Expected tree-flatten: parent, its child, then the unrelated root.
self.assertEqual(names, ['nsP', 'nsPchild', 'nsP1'])

View File

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

View File

@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from netbox.graphql.types import NestedGroupObjectType, PrimaryObjectType
from netbox.graphql.types import NestedLtreeGroupObjectType, PrimaryObjectType
from wireless import models
from .filters import *
@ -22,11 +22,11 @@ __all__ = (
@strawberry_django.type(
models.WirelessLANGroup,
fields='__all__',
exclude=['path', 'sort_path'],
filters=WirelessLANGroupFilter,
pagination=True
)
class WirelessLANGroupType(NestedGroupObjectType):
class WirelessLANGroupType(NestedLtreeGroupObjectType):
parent: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None
wireless_lans: list[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]]

View File

@ -0,0 +1,86 @@
"""Replace django-mptt with PostgreSQL ltree for wireless's hierarchical models.
The reverse migration is lossy: it re-adds the MPTT columns empty and does not
rebuild the tree. Forward migration is the supported direction.
"""
import django.db.models.deletion
from django.contrib.postgres.indexes import GistIndex
from django.contrib.postgres.operations import CreateExtension
from django.db import migrations, models
import netbox.models.ltree
from utilities.ltree import InstallLtreeTriggers
from utilities.mptt_to_ltree import assert_paths_populated_sql, populate_paths_sql
MODEL = 'wirelesslangroup'
TABLE = 'wireless_wirelesslangroup'
LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
class Migration(migrations.Migration):
dependencies = [
('wireless', '0019_default_ordering_indexes'),
]
operations = [
# Enable the ltree extension first so the migration fails fast if it is missing.
CreateExtension('ltree'),
migrations.AlterField(
model_name='wirelesslangroup', name='parent',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='children', to='wireless.wirelesslangroup',
),
),
migrations.AddField(
model_name=MODEL, name='path',
field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
),
# sort_path uses natural_sort to match the WirelessLANGroup.name collation.
migrations.AddField(
model_name=MODEL, name='sort_path',
field=models.TextField(
blank=True, default='', editable=False, db_collation='natural_sort',
),
),
InstallLtreeTriggers(TABLE, name_column='name'),
# Populate path and sort_path for existing rows by walking the tree from
# the roots (parent_id IS NULL) downward via a single recursive CTE.
migrations.RunSQL(
populate_paths_sql(TABLE, sort_path=True),
reverse_sql=migrations.RunSQL.noop,
),
# Fail fast if any row still has NULL path (orphan FKs) before the
# AlterField below tries to set NOT NULL inside ALTER COLUMN.
migrations.RunSQL(
assert_paths_populated_sql(TABLE),
reverse_sql=migrations.RunSQL.noop,
),
migrations.AlterField(
model_name=MODEL, name='path',
field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
),
migrations.AlterModelOptions(
name=MODEL, options={'ordering': ('sort_path',)},
),
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'),
),
migrations.AddIndex(
model_name=MODEL,
index=models.Index(fields=['sort_path'], name='wireless_lan_grp_sort_idx'),
),
]

View File

@ -1,3 +1,4 @@
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -5,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
from dcim.models.mixins import CachedScopeMixin
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models import NestedLtreeGroupModel, PrimaryModel
from netbox.models.mixins import DistanceMixin
from .choices import *
@ -46,7 +47,7 @@ class WirelessAuthenticationBase(models.Model):
abstract = True
class WirelessLANGroup(NestedGroupModel):
class WirelessLANGroup(NestedLtreeGroupModel):
"""
A nested grouping of WirelessLANs
"""
@ -61,12 +62,14 @@ class WirelessLANGroup(NestedGroupModel):
max_length=100,
unique=True
)
# sort_path inherits natural_sort collation from `name` automatically (LtreeModelBase).
class Meta:
ordering = ('name', 'pk')
# Empty tuple triggers Django migration detection for MPTT indexes
# (see #21016, django-mptt/django-mptt#682)
indexes = ()
ordering = ('sort_path',)
indexes = (
GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
models.Index(fields=['sort_path'], name='wireless_lan_grp_sort_idx'),
)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),