Efficient tree implementations for Django models providing three different tree algorithms with unified API
—
Form classes and UI components for tree manipulation in Django applications. These forms provide user-friendly interfaces for moving nodes, creating hierarchical structures, and managing tree data with proper validation and error handling.
Main form class for moving nodes within tree structures, with dropdown tree selection and position controls.
class MoveNodeForm(forms.ModelForm):
"""
Form for moving nodes within tree structure.
Provides dropdown tree selection and position options
based on whether the tree uses node_order_by.
"""
_position = forms.ChoiceField(
label="Position",
help_text="Choose where to move the node"
)
_ref_node_id = forms.ChoiceField(
label="Reference Node",
help_text="Choose the reference node for the move"
)
def save(self, commit=True):
"""
Save form and perform node move operation.
Parameters:
commit (bool): Whether to commit the move immediately
Returns:
Node: The moved node instance
Raises:
InvalidPosition: If position is invalid
InvalidMoveToDescendant: If moving to descendant
"""
def mk_dropdown_tree(self, model, for_node=None):
"""
Create hierarchical dropdown choices for node selection.
Parameters:
model (Model): Tree model class
for_node (Node, optional): Node being moved (excluded from choices)
Returns:
list: List of (value, label) tuples for dropdown
"""
def add_subtree(self, for_node, node, options):
"""
Recursively build tree options with indentation.
Parameters:
for_node (Node): Node being moved (to exclude descendants)
node (Node): Current node being processed
options (list): List to append options to
"""
def is_loop_safe(self, for_node, possible_parent):
"""
Check if move would create circular reference.
Parameters:
for_node (Node): Node being moved
possible_parent (Node): Potential new parent
Returns:
bool: True if move is safe (no circular reference)
"""
def mk_indent(self, level):
"""
Create indentation markup for tree display.
Parameters:
level (int): Indentation level (0 for root)
Returns:
str: HTML markup for indentation
"""Factory function for creating customized MoveNodeForm subclasses.
def movenodeform_factory(model, form=MoveNodeForm, fields=None, exclude=None,
formfield_callback=None, widgets=None):
"""
Create MoveNodeForm subclass for specific model.
Parameters:
model (Model): Tree model class
form (Form): Base form class (default: MoveNodeForm)
fields (list, optional): Fields to include in form
exclude (list, optional): Fields to exclude from form
formfield_callback (callable, optional): Custom field generation
widgets (dict, optional): Custom widget overrides
Returns:
type: MoveNodeForm subclass configured for the model
"""# forms.py
from django import forms
from treebeard.forms import movenodeform_factory
from .models import Category
# Create form class for Category model
CategoryMoveForm = movenodeform_factory(Category)
# Or create with custom fields
CategoryMoveForm = movenodeform_factory(
Category,
fields=['name', 'description', 'active']
)from django import forms
from treebeard.forms import MoveNodeForm
from .models import Category
class CategoryMoveForm(MoveNodeForm):
"""Custom move form with additional validation."""
class Meta:
model = Category
fields = ['name', 'description', 'active']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Customize position choices
if self.instance.pk:
# Filter available positions based on business logic
self.fields['_position'].choices = self.get_custom_positions()
def get_custom_positions(self):
"""Get position choices based on business rules."""
positions = []
if self.instance.node_order_by:
positions.extend([
('sorted-child', 'Child of (sorted)'),
('sorted-sibling', 'Sibling of (sorted)'),
])
else:
positions.extend([
('first-child', 'First child of'),
('last-child', 'Last child of'),
('left', 'Before'),
('right', 'After'),
])
return positions
def clean(self):
"""Additional validation for move operations."""
cleaned_data = super().clean()
position = cleaned_data.get('_position')
ref_node_id = cleaned_data.get('_ref_node_id')
if position and ref_node_id:
try:
ref_node = Category.objects.get(pk=ref_node_id)
# Business rule: Can't move to different root tree
if self.instance.get_root() != ref_node.get_root():
raise forms.ValidationError(
"Cannot move node to different root tree"
)
# Business rule: Active nodes can't be children of inactive nodes
if (position in ['first-child', 'last-child', 'sorted-child']
and self.instance.active and not ref_node.active):
raise forms.ValidationError(
"Active categories cannot be children of inactive categories"
)
except Category.DoesNotExist:
raise forms.ValidationError("Invalid reference node")
return cleaned_data# views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from .models import Category
from .forms import CategoryMoveForm
@login_required
def move_category(request, category_id):
"""View for moving categories."""
category = get_object_or_404(Category, pk=category_id)
if request.method == 'POST':
form = CategoryMoveForm(request.POST, instance=category)
if form.is_valid():
try:
form.save()
messages.success(
request,
f'Successfully moved "{category.name}"'
)
return redirect('category_list')
except Exception as e:
messages.error(request, f'Move failed: {str(e)}')
else:
messages.error(request, 'Please correct the errors below')
else:
form = CategoryMoveForm(instance=category)
return render(request, 'categories/move.html', {
'form': form,
'category': category
})
def category_tree_json(request):
"""API endpoint for tree data (for AJAX forms)."""
categories = Category.get_annotated_list()
tree_data = []
for item in categories:
tree_data.append({
'id': item['node'].pk,
'name': item['node'].name,
'level': item['level'],
'open': item.get('open', False),
'close': item.get('close', False)
})
return JsonResponse({'tree': tree_data})<!-- templates/categories/move.html -->
<form method="post">
{% csrf_token %}
<div class="form-group">
<label>Category to Move:</label>
<div class="form-control-static">
{{ category.name }}
<small class="text-muted">
(Current position: Level {{ category.get_depth }})
</small>
</div>
</div>
<!-- Regular model fields -->
{% for field in form %}
{% if not field.name.startswith('_') %}
<div class="form-group">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
<!-- Move-specific fields -->
<fieldset class="border p-3 mb-3">
<legend class="w-auto px-2">Move Options</legend>
<div class="form-group">
<label for="{{ form._ref_node_id.id_for_label }}">
{{ form._ref_node_id.label }}
</label>
{{ form._ref_node_id }}
{% if form._ref_node_id.help_text %}
<small class="form-text text-muted">{{ form._ref_node_id.help_text }}</small>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form._position.id_for_label }}">
{{ form._position.label }}
</label>
{{ form._position }}
{% if form._position.help_text %}
<small class="form-text text-muted">{{ form._position.help_text }}</small>
{% endif %}
</div>
</fieldset>
<div class="form-group">
<button type="submit" class="btn btn-primary">Move Category</button>
<a href="{% url 'category_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
<script>
// Optional: Dynamic position options based on reference node
document.getElementById('id__ref_node_id').addEventListener('change', function() {
const refNodeId = this.value;
const positionField = document.getElementById('id__position');
// Fetch available positions for selected reference node
if (refNodeId) {
fetch(`/api/categories/${refNodeId}/positions/`)
.then(response => response.json())
.then(data => {
positionField.innerHTML = '';
data.positions.forEach(pos => {
const option = document.createElement('option');
option.value = pos.value;
option.textContent = pos.label;
positionField.appendChild(option);
});
});
}
});
</script>Create dynamic tree selection with AJAX loading:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["GET"])
def get_tree_nodes(request):
"""API endpoint for dynamic tree loading."""
parent_id = request.GET.get('parent_id')
search = request.GET.get('search', '')
if parent_id:
try:
parent = Category.objects.get(pk=parent_id)
nodes = parent.get_children()
except Category.DoesNotExist:
nodes = Category.objects.none()
else:
nodes = Category.get_root_nodes()
if search:
nodes = nodes.filter(name__icontains=search)
data = []
for node in nodes:
data.append({
'id': node.pk,
'name': node.name,
'has_children': node.get_children_count() > 0,
'level': node.get_depth()
})
return JsonResponse({'nodes': data})
class AjaxCategoryMoveForm(MoveNodeForm):
"""Move form with AJAX tree selection."""
class Meta:
model = Category
fields = ['name', 'description']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Use AJAX widget for node selection
self.fields['_ref_node_id'].widget = forms.Select(
attrs={
'class': 'ajax-tree-select',
'data-ajax-url': '/api/categories/tree-nodes/'
}
)Create inline forms for tree editing:
from django.forms import inlineformset_factory
# Create formset for editing tree structure
CategoryFormSet = inlineformset_factory(
Category, # Parent model
Category, # Child model (self-reference)
form=CategoryMoveForm,
fk_name='parent', # For AL_Node
extra=1,
can_delete=True
)
def edit_category_tree(request, category_id):
"""Edit category and its children."""
category = get_object_or_404(Category, pk=category_id)
if request.method == 'POST':
formset = CategoryFormSet(request.POST, instance=category)
if formset.is_valid():
formset.save()
messages.success(request, 'Tree updated successfully')
return redirect('edit_category_tree', category_id=category.pk)
else:
formset = CategoryFormSet(instance=category)
return render(request, 'categories/edit_tree.html', {
'category': category,
'formset': formset
})Common validation patterns for tree forms:
class TreeValidationMixin:
"""Mixin with common tree validation methods."""
def validate_max_depth(self, max_depth=5):
"""Validate that move doesn't exceed maximum depth."""
position = self.cleaned_data.get('_position')
ref_node_id = self.cleaned_data.get('_ref_node_id')
if position and ref_node_id and 'child' in position:
ref_node = self.Meta.model.objects.get(pk=ref_node_id)
new_depth = ref_node.get_depth() + 1
if new_depth > max_depth:
raise forms.ValidationError(
f'Maximum tree depth ({max_depth}) would be exceeded'
)
def validate_business_rules(self):
"""Validate business-specific rules."""
# Example: Categories with products can't be moved
if hasattr(self.instance, 'products') and self.instance.products.exists():
raise forms.ValidationError(
'Categories containing products cannot be moved'
)
def clean(self):
cleaned_data = super().clean()
self.validate_max_depth()
self.validate_business_rules()
return cleaned_data
class CategoryMoveForm(TreeValidationMixin, MoveNodeForm):
"""Category move form with validation."""
class Meta:
model = Category
fields = ['name', 'description']from django import forms
from django.utils.safestring import mark_safe
class TreeSelectWidget(forms.Select):
"""Custom widget for tree node selection."""
def __init__(self, tree_model, *args, **kwargs):
self.tree_model = tree_model
super().__init__(*args, **kwargs)
def render(self, name, value, attrs=None, renderer=None):
"""Render tree selection widget."""
html = ['<select name="{}" id="id_{}">'.format(name, name)]
if not value:
html.append('<option value="">---------</option>')
# Build tree options
for node in self.tree_model.get_annotated_list():
indent = ' ' * (node['level'] * 4)
selected = 'selected' if str(node['node'].pk) == str(value) else ''
html.append(
'<option value="{}" {}>{}{}</option>'.format(
node['node'].pk,
selected,
indent,
node['node'].name
)
)
html.append('</select>')
return mark_safe(''.join(html))
# Usage in form
class CategoryMoveForm(MoveNodeForm):
class Meta:
model = Category
fields = ['name']
widgets = {
'_ref_node_id': TreeSelectWidget(Category)
}For large trees, consider:
class OptimizedCategoryMoveForm(MoveNodeForm):
"""Optimized form for large trees."""
def mk_dropdown_tree(self, model, for_node=None):
"""Optimized dropdown with depth limits."""
options = [('', '---------')]
# Limit to reasonable depth for UI
max_depth = 4
nodes = model.objects.filter(depth__lte=max_depth)
# Use annotated list for efficiency
for item in model.get_annotated_list_qs(nodes):
if for_node and self.is_loop_safe(for_node, item['node']):
continue
indent = self.mk_indent(item['level'])
label = f"{indent}{item['node'].name}"
options.append((item['node'].pk, label))
return optionsInstall with Tessl CLI
npx tessl i tessl/pypi-django-treebeard