Skip to main content

django-icv-tree

CI PyPI version Python versions Django versions Licence: MIT

Hierarchical data in Django without the complexity. django-icv-tree stores tree structures as materialised paths: every node knows its full ancestry in a single indexed column, so ancestor, descendant, and sibling queries are fast prefix lookups rather than recursive joins or nested set bookkeeping.

One abstract model, one manager, one queryset. Every traversal method returns a lazy QuerySet: no Python list coercions, no surprise N+1 queries. Configurable path format, async-safe, zero tenancy coupling.

Replaces django-mptt, django-treebeard (materialised path), and django-polymorphic-tree with a simpler, single-file API.

pip install django-icv-tree

Boundaries

django-icv-tree deliberately does not:

  • Support nested set or closure table representations. Materialised path is the only tree strategy, by design.
  • Handle polymorphic model inheritance beyond the multi-table-inheritance routing _tree_model() needs internally. django-polymorphic integration, if wanted, is the consuming project's responsibility.
  • Implement multi-tenancy. tree_scope_field lets a consuming project partition one table into independent trees, but the scope value itself, and any tenant isolation around it, is the consuming project's own concern.
  • Track history or versioning of node moves. node_moved and tree_rebuilt signals exist for a consumer to build an audit trail on, but icv-tree keeps none itself.
  • Ship drag-and-drop JavaScript. TreeAdmin provides the tree_move_node AJAX endpoint and URL wiring; the consuming project supplies the frontend (SortableJS, jsTree, or similar).
  • Provide REST API endpoints. No DRF viewsets or URL routes beyond the admin move endpoint; wiring tree data into an API layer is the consuming project's job.
  • Integrate with search. No coupling to django-icv-search or any other indexer.
  • Cache anything. Every traversal method is a live queryset; a consuming project applies Django's cache framework itself if it wants one.
  • Authorise anything. move_to(), rebuild(), and the admin actions carry no permission checks of their own; access control is entirely the consuming project's ModelAdmin permissions and application code.

Quick start

# models.py
from django.db import models
from icv_tree.models import TreeNode

class Category(TreeNode):
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name
# settings.py
INSTALLED_APPS = [
    # ...
    "icv_tree",
    "myapp",
]
python manage.py makemigrations myapp
python manage.py migrate
root = Category(name="Electronics", parent=None)
root.save()                               # path="0001", depth=0, order=0

phones = Category(name="Phones", parent=root)
phones.save()                             # path="0001/0001", depth=1, order=0

cases = Category(name="Cases", parent=phones)
cases.save()                              # path="0001/0001/0001", depth=2, order=0

Path, depth, and order are computed automatically on save; you never set them manually.


Traversal

Every method returns a lazy QuerySet that you can filter, slice, and chain:

# Instance methods
node.get_ancestors()              # root -> ... -> parent, ordered by depth
node.get_ancestors(include_self=True)
node.get_descendants()            # depth-first, ordered by path
node.get_descendants(include_self=True)
node.get_children()               # direct children, ordered by sibling order
node.get_siblings()               # same parent, excluding self
node.get_siblings(include_self=True)
node.get_root()                   # root of this node's tree
node.get_descendant_count()       # COUNT query
node.is_root()                    # bool, no DB hit
node.is_leaf()                    # bool, EXISTS query

Manager and QuerySet methods

The same traversal is available on the manager and as chainable queryset filters:

# Manager
Category.objects.roots()                    # all root nodes
Category.objects.at_depth(2)                # all nodes at depth 2
Category.objects.ancestors_of(node)
Category.objects.descendants_of(node)
Category.objects.children_of(node)
Category.objects.siblings_of(node)

# QuerySet: chain with any Django filter
Category.objects.descendants_of(node).filter(is_active=True)
Category.objects.with_tree_fields()         # annotates is_root, child_count

Moving nodes

from icv_tree.services import move_to

move_to(node, target, position="last-child")
# or
node.move_to(target, position="first-child")

Positions: first-child, last-child, left, right.

Moves are atomic (transaction.atomic), recompute paths for the entire subtree, and reorder siblings at both source and destination. A node_moved signal is emitted after commit.

Cycle detection prevents moving a node under its own descendant.

Reordering a subset of siblings

move_to changes how many slots a sibling list has (it inserts into, or removes a node from, the list). For the common case of reordering rows that already share a parent, reorder_siblings is a narrower, cheaper primitive that never adds or removes a slot:

from icv_tree.services import reorder_siblings

reorder_siblings(Category, ordered_ids=[c3.pk, c1.pk, c2.pk])

ordered_ids names a set of sibling rows (they must all share one parent, which may be None for roots) and gives the sequence you want them in. The listed rows are permuted across the (order, path) slots they already occupy: the current slots are collected and sorted, then handed out to the rows in the order you gave. Every sibling that is not listed, including other siblings of the same parent interleaved between the listed ones, is left completely untouched, its path, depth, and order are byte-for-byte unchanged.

This is what lets a consumer reorder only the rows it owns within a shared sibling list, for example a root sibling list that spans several tree_scope_field values, without touching any other scope's roots and without a rebuild.

reorder_siblings is atomic, raises TreeStructureError for an empty or duplicate id list, an unknown id, or ids that do not all share one parent, and does not emit a signal (there is no single-node shape for a multi-row permutation).


Rebuilding

If paths get out of sync (bulk imports, raw SQL, migrations), rebuild from the parent FK adjacency list:

Category.objects.rebuild()
# or
python manage.py icv_tree_rebuild --model=myapp.Category

Options:

  • --dry-run: report what would change without writing
  • --check: run integrity checks only, exit 1 if issues found
  • --scope: restrict the rebuild to one tree_scope_field value (see below)

On PostgreSQL with ICV_TREE_ENABLE_CTE = True, rebuild uses a recursive CTE for better performance on large trees.

Scoped rebuilds

For a model that sets tree_scope_field (see TreeNode's docstring for the full path-scoping contract), a full rebuild reconstructs every scope in one pass. To rebuild just one scope's tree, pass scope=:

Term.objects.rebuild(scope=vocabulary)
# or
python manage.py icv_tree_rebuild --model=myapp.Term --scope=5

A scoped rebuild only reads, clears, and writes rows in the given scope. Every other scope's rows, including their path, depth, and order, are left completely untouched. This is safe because a scoped model's uniqueness constraint covers (scope_field, path), not path alone, so a scoped rebuild's transient placeholder values can never collide with another scope's real paths.

Passing scope to a model that does not define tree_scope_field raises ImproperlyConfigured.


Integrity checks

from icv_tree.services import check_tree_integrity

result = check_tree_integrity(Category)
# {
#     "orphaned_nodes": [],
#     "depth_mismatches": [],
#     "path_prefix_violations": [],
#     "duplicate_paths": [],
#     "total_issues": 0,
# }

Two kinds of system check ship with the package. The data-integrity checks read the database and are not registered with Django's check framework: run them with manage.py icv_tree_rebuild --check or call check_all_tree_models() from your own CI step.

  • icv_tree.E001: orphaned nodes (parent references missing row)
  • icv_tree.E002: path inconsistencies (depth mismatch, prefix violation, duplicates)

The declaration check reads only model metadata and is registered, so it runs on every manage.py check, migrate and runserver:

  • icv_tree.W001: concrete model declares no uniqueness constraint on path (or on (tree_scope_field, path) for a scoped model); a Warning for now, becoming an Error at the next major release

Models can opt out with check_tree_integrity = False on the class.


Signals

from icv_tree.signals import node_moved, tree_rebuilt

@receiver(node_moved)
def on_move(sender, instance, old_parent, new_parent, old_path, **kwargs):
    # Invalidate cache, re-index search, etc.
    pass

@receiver(tree_rebuilt)
def on_rebuild(sender, nodes_updated, nodes_unchanged, scope, **kwargs):
    # scope is the value rebuild() was restricted to, or None for a full rebuild.
    pass

Both signals fire after the transaction commits.


Admin

from django.contrib import admin
from icv_tree.admin import TreeAdmin

@admin.register(Category)
class CategoryAdmin(TreeAdmin, admin.ModelAdmin):
    list_display = ["name"]

TreeAdmin provides:

  • Indented list display proportional to node depth
  • Read-only path, depth, and order fields
  • A move endpoint (POST <pk>/tree-move/) that accepts target_id and position and calls move_to(). This is a server-side hook only: the package does not ship any client-side drag-and-drop JavaScript. TreeAdmin.Media.js is an empty tuple by design, so wiring an actual drag-and-drop UI (SortableJS, jsTree, or your own) that POSTs to this endpoint is the consuming project's responsibility.

Template tags

{% load icv_tree %}

<!-- Recursive tree rendering -->
{% recurse_tree root_nodes %}
    <li>
        {{ node.name }}
        {% if children %}
        <ul>
            {% recurse_tree children %}
                <li>{{ node.name }}</li>
            {% end_recurse_tree %}
        </ul>
        {% endif %}
    </li>
{% end_recurse_tree %}

<!-- Breadcrumbs -->
{% tree_breadcrumbs node as crumbs %}
{% for crumb in crumbs %}
    <a href="{{ crumb.get_absolute_url }}">{{ crumb }}</a>
{% endfor %}

<!-- Filter: is_ancestor_of -->
{% if node|is_ancestor_of:current_node %}active{% endif %}

Migration operation

For optimal prefix-query performance, add a PathIndex in your migration:

from icv_tree.operations import PathIndex

class Migration(migrations.Migration):
    operations = [
        migrations.CreateModel(name="Category", fields=[...]),
        PathIndex(model_name="category", field_name="path"),
    ]

On PostgreSQL this creates a text_pattern_ops index for efficient LIKE 'path/%' queries. On other databases it creates a standard B-tree index.


Testing utilities

Factory base classes

# myapp/factories.py
import factory
from icv_tree.testing.factories import TreeNodeFactory

class CategoryFactory(TreeNodeFactory):
    class Meta:
        model = Category

    name = factory.Sequence(lambda n: f"Category {n}")

# Usage
root = CategoryFactory()
child = CategoryFactory(parent=root)

Test mixin

from icv_tree.testing import TreeTestMixin

class TestCategoryTree(TreeTestMixin, TestCase):

    def test_tree_is_valid(self):
        self.assert_tree_valid(Category)

    def test_ancestry(self):
        self.assert_is_ancestor_of(root, child)
        self.assert_is_descendant_of(child, root)

    def test_build_tree(self):
        nodes = self.create_tree_structure(Category, {
            "Electronics": {
                "Phones": {"Cases": {}},
                "Laptops": {},
            },
        })
        assert nodes["Cases"].depth == 2

pytest fixture

# conftest.py
from icv_tree.testing.fixtures import tree_integrity_checker  # noqa: F401

# tests
def test_my_tree(tree_integrity_checker):
    # ... build tree ...
    tree_integrity_checker(Category)

Settings

All settings use the ICV_TREE_* prefix and have sensible defaults:

Setting Default Description
ICV_TREE_PATH_SEPARATOR "/" Single character separating path segments. Must not be a digit.
ICV_TREE_STEP_LENGTH 4 Digits per path segment. 4 supports up to 9,999 siblings. Range: 1-10.
ICV_TREE_MAX_PATH_LENGTH 255 Max CharField length. With defaults: 51 levels deep.
ICV_TREE_ENABLE_CTE False Use PostgreSQL recursive CTE for rebuild. No effect on other databases.
ICV_TREE_REBUILD_BATCH_SIZE 1000 Nodes per bulk_update batch during rebuild.
ICV_TREE_CHECK_ON_SAVE False On a same-parent save with a hand-edited path, depth or order, raise TreeStructureError naming the field and both values. When False, the same mismatch is logged once through the icv_tree logger instead, and the save proceeds unchanged. Read at call time, so it can be overridden per test.

Warning: Changing ICV_TREE_PATH_SEPARATOR or ICV_TREE_STEP_LENGTH after data exists will invalidate all stored paths. Run rebuild() after changing.


Requirements

  • Python 3.11+
  • Django 5.1+

Optional: factory-boy for TreeNodeFactory.


Licence

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_icv_tree-1.3.0.tar.gz (88.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_icv_tree-1.3.0-py3-none-any.whl (55.3 kB view details)

Uploaded Python 3

File details

Details for the file django_icv_tree-1.3.0.tar.gz.

File metadata

  • Download URL: django_icv_tree-1.3.0.tar.gz
  • Upload date:
  • Size: 88.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_icv_tree-1.3.0.tar.gz
Algorithm Hash digest
SHA256 8f42adfda7ccfd2a953452cc6a2d92e8b7e2ab383e21c1c5acca213871246927
MD5 962628d2db9db047a98295d625b420c5
BLAKE2b-256 6beda2b3c57118105ead470d34d902cda96d27be29368caa27549c1e2723e96b

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_icv_tree-1.3.0.tar.gz:

Publisher: publish.yml on icvoss/django-icv-tree

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_icv_tree-1.3.0-py3-none-any.whl.

File metadata

  • Download URL: django_icv_tree-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 55.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_icv_tree-1.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 99cf018c941d89cca16012804a2c4a9f65460ddca09db34dd5fe239dbb2e7d0d
MD5 db41e8a2eeaa19817be88665ea9b9eae
BLAKE2b-256 719b17e73b50a7d5001d1b59f4a564871197c15a26b5860bd7e1e87f60717d45

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_icv_tree-1.3.0-py3-none-any.whl:

Publisher: publish.yml on icvoss/django-icv-tree

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 files

1.2.0

2 files

1.1.1

2 files

1.1.0

2 files

1.0.0

2 files

0.4.0

2 files

0.3.1

2 files

0.3.0

2 files

0.2.1

2 files

0.2.0

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page