CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/pypi-django-treebeard

Efficient tree implementations for Django models providing three different tree algorithms with unified API

Pending
Overview
Eval results
Files

forms-ui.mddocs/

Forms and UI

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.

Capabilities

MoveNodeForm Class

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
        """

Form Factory Function

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
    """

Usage Examples

Basic Form Setup

# 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']
)

Custom Form Implementation

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

View Integration

# 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})

Template Usage

<!-- 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>

Advanced Form Features

AJAX Tree Selection

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/'
            }
        )

Inline Tree Forms

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

Validation Helpers

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']

Widget Customization

Custom Tree Widget

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 = '&nbsp;' * (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)
        }

Performance Considerations

For large trees, consider:

  1. Pagination: Limit dropdown options
  2. AJAX Loading: Load tree nodes on demand
  3. Caching: Cache tree structure for read-heavy forms
  4. Lazy Loading: Load subtrees as needed
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 options

Install with Tessl CLI

npx tessl i tessl/pypi-django-treebeard

docs

admin-integration.md

forms-ui.md

index.md

tree-models.md

tree-navigation.md

tree-operations.md

utilities.md

tile.json