Skip to content

Commit 6be8a54

Browse files
committed
Fix #101: Introduce an ordered tree node base class and recommend using it
1 parent 50d6983 commit 6be8a54

File tree

4 files changed

+307
-6
lines changed

4 files changed

+307
-6
lines changed

README.rst

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ Tree node with ordering among siblings
111111
Nodes with the same parent may be ordered among themselves. The default is to
112112
order siblings by their primary key but that's not always very useful.
113113

114+
**Manual position management:**
115+
114116
.. code-block:: python
115117
116118
from tree_queries.models import TreeNode
@@ -122,6 +124,54 @@ order siblings by their primary key but that's not always very useful.
122124
class Meta:
123125
ordering = ["position"]
124126
127+
**Automatic position management:**
128+
129+
For automatic position management, use ``OrderableTreeNode`` which automatically
130+
assigns sequential position values to new nodes:
131+
132+
.. code-block:: python
133+
134+
from tree_queries.models import OrderableTreeNode
135+
136+
class Category(OrderableTreeNode):
137+
name = models.CharField(max_length=100)
138+
# position field and ordering are inherited from OrderableTreeNode
139+
140+
When creating new nodes without an explicit position, ``OrderableTreeNode``
141+
automatically assigns a position value 10 units higher than the maximum position
142+
among siblings. The increment of 10 (rather than 1) makes it explicit that the
143+
position values themselves have no inherent meaning - they are purely for relative
144+
ordering, not a sibling counter or index.
145+
146+
If you need to customize the Meta class (e.g., to add verbose names or additional
147+
ordering fields), inherit from ``OrderableTreeNode.Meta``:
148+
149+
.. code-block:: python
150+
151+
from tree_queries.models import OrderableTreeNode
152+
153+
class Category(OrderableTreeNode):
154+
name = models.CharField(max_length=100)
155+
156+
class Meta(OrderableTreeNode.Meta):
157+
verbose_name = "category"
158+
verbose_name_plural = "categories"
159+
# ordering = ["position"] is inherited from OrderableTreeNode.Meta
160+
161+
.. code-block:: python
162+
163+
# Create nodes - positions are assigned automatically
164+
root = Category.objects.create(name="Root") # position=10
165+
child1 = Category.objects.create(name="Child 1", parent=root) # position=10
166+
child2 = Category.objects.create(name="Child 2", parent=root) # position=20
167+
child3 = Category.objects.create(name="Child 3", parent=root) # position=30
168+
169+
# Manual reordering is still possible
170+
child3.position = 15 # Move between child1 and child2
171+
child3.save()
172+
173+
This approach is identical to the pattern used in feincms3's ``AbstractPage``.
174+
125175

126176
Add custom methods to queryset
127177
------------------------------
@@ -538,16 +588,63 @@ To use the admin functionality, install with the ``admin`` extra:
538588
Usage
539589
-----
540590

591+
**With automatic position management:**
592+
593+
For the best admin experience with proper ordering, use ``OrderableTreeNode``:
594+
541595
.. code-block:: python
542596
543597
from django.contrib import admin
544598
from tree_queries.admin import TreeAdmin
545-
from .models import Category
599+
from tree_queries.models import OrderableTreeNode
600+
601+
class Category(OrderableTreeNode):
602+
name = models.CharField(max_length=100)
603+
# position field and ordering are inherited from OrderableTreeNode
604+
605+
@admin.register(Category)
606+
class CategoryAdmin(TreeAdmin):
607+
list_display = [*TreeAdmin.list_display, "name"]
608+
position_field = "position" # Enables sibling ordering controls
609+
610+
**With manual position management:**
611+
612+
If you prefer to manage positions yourself:
613+
614+
.. code-block:: python
615+
616+
from django.contrib import admin
617+
from django.db.models import Max
618+
from tree_queries.admin import TreeAdmin
619+
from tree_queries.models import TreeNode
620+
621+
class Category(TreeNode):
622+
name = models.CharField(max_length=100)
623+
position = models.PositiveIntegerField(default=0)
624+
625+
class Meta:
626+
ordering = ["position"]
627+
628+
def save(self, *args, **kwargs):
629+
# Custom position logic here
630+
if not self.position:
631+
self.position = (
632+
10
633+
+ (
634+
self.__class__._default_manager.filter(parent_id=self.parent_id)
635+
.order_by()
636+
.aggregate(p=Max("position"))["p"]
637+
or 0
638+
)
639+
)
640+
super().save(*args, **kwargs)
641+
642+
save.alters_data = True
546643
547644
@admin.register(Category)
548645
class CategoryAdmin(TreeAdmin):
549-
list_display = [*TreeAdmin.list_display, "name", "is_active"]
550-
position_field = "position" # Optional: field used for sibling ordering
646+
list_display = [*TreeAdmin.list_display, "name"]
647+
position_field = "position"
551648
552649
The ``TreeAdmin`` provides:
553650

@@ -617,5 +714,6 @@ maintain their relative order after the migration.
617714

618715
Note that the position field is used purely for ordering siblings and is not an
619716
index. By default, django-tree-queries' admin interface starts with a position
620-
value of 10 and increments by 10 (10, 20, 30, etc.) to make it clear that the
621-
value is not an index, but just something to order siblings by.
717+
value of 10 and increments by 10 (10, 20, 30, etc.) to make it explicit that the
718+
position values themselves have no inherent meaning - they are purely for relative
719+
ordering, not a sibling counter or index.

tests/testapp/models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from django.db import models
44

5-
from tree_queries.models import TreeNode
5+
from tree_queries.models import OrderableTreeNode, TreeNode
66
from tree_queries.query import TreeQuerySet
77

88

@@ -139,3 +139,10 @@ class OneToOneRelatedOrder(models.Model):
139139

140140
def __str__(self):
141141
return ""
142+
143+
144+
class OrderedModel(OrderableTreeNode):
145+
name = models.CharField(max_length=100)
146+
147+
def __str__(self):
148+
return self.name

tests/testapp/test_ordered.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from django.test import TestCase
2+
3+
from .models import OrderedModel
4+
5+
6+
class OrderableTreeNodeTestCase(TestCase):
7+
def test_automatic_position_assignment(self):
8+
"""Test that position is automatically assigned to new nodes"""
9+
root = OrderedModel.objects.create(name="Root")
10+
assert root.position == 10
11+
12+
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
13+
assert child1.position == 10
14+
15+
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
16+
assert child2.position == 20
17+
18+
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
19+
assert child3.position == 30
20+
21+
def test_manual_position_respected(self):
22+
"""Test that manually set positions are not overwritten"""
23+
root = OrderedModel.objects.create(name="Root", position=100)
24+
assert root.position == 100
25+
26+
child = OrderedModel.objects.create(name="Child", parent=root, position=50)
27+
assert child.position == 50
28+
29+
def test_position_increments_from_max(self):
30+
"""Test that positions increment from the current maximum"""
31+
root = OrderedModel.objects.create(name="Root")
32+
33+
# Create children with custom positions
34+
OrderedModel.objects.create(name="Child 1", parent=root, position=100)
35+
OrderedModel.objects.create(name="Child 2", parent=root, position=200)
36+
37+
# Next auto-assigned position should be max + 10
38+
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
39+
assert child3.position == 210
40+
41+
def test_siblings_ordered_by_position(self):
42+
"""Test that siblings are correctly ordered by position"""
43+
root = OrderedModel.objects.create(name="Root")
44+
45+
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
46+
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
47+
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
48+
49+
siblings = list(root.children.all())
50+
assert siblings[0] == child1
51+
assert siblings[1] == child2
52+
assert siblings[2] == child3
53+
54+
def test_reordering_siblings(self):
55+
"""Test that siblings can be manually reordered"""
56+
root = OrderedModel.objects.create(name="Root")
57+
58+
child1 = OrderedModel.objects.create(name="Child 1", parent=root) # position=10
59+
child2 = OrderedModel.objects.create(name="Child 2", parent=root) # position=20
60+
child3 = OrderedModel.objects.create(name="Child 3", parent=root) # position=30
61+
62+
# Move child3 between child1 and child2
63+
child3.position = 15
64+
child3.save()
65+
66+
siblings = list(root.children.all())
67+
assert siblings[0] == child1
68+
assert siblings[1] == child3
69+
assert siblings[2] == child2
70+
71+
def test_position_per_parent(self):
72+
"""Test that positions are assigned per parent"""
73+
root1 = OrderedModel.objects.create(name="Root 1")
74+
root2 = OrderedModel.objects.create(name="Root 2")
75+
76+
child1_1 = OrderedModel.objects.create(name="Child 1-1", parent=root1)
77+
child2_1 = OrderedModel.objects.create(name="Child 2-1", parent=root2)
78+
79+
# Both should get position 10 since they have different parents
80+
assert child1_1.position == 10
81+
assert child2_1.position == 10
82+
83+
child1_2 = OrderedModel.objects.create(name="Child 1-2", parent=root1)
84+
child2_2 = OrderedModel.objects.create(name="Child 2-2", parent=root2)
85+
86+
# Both should get position 20
87+
assert child1_2.position == 20
88+
assert child2_2.position == 20
89+
90+
def test_ordering_with_tree_queries(self):
91+
"""Test that ordering works correctly with tree queries"""
92+
root = OrderedModel.objects.create(name="Root")
93+
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
94+
OrderedModel.objects.create(name="Grandchild 1", parent=child1)
95+
OrderedModel.objects.create(name="Grandchild 2", parent=child1)
96+
OrderedModel.objects.create(name="Child 2", parent=root)
97+
98+
# Get tree with tree fields
99+
nodes = list(OrderedModel.objects.with_tree_fields())
100+
101+
# Verify depth-first order respects position ordering
102+
assert nodes[0].name == "Root"
103+
assert nodes[1].name == "Child 1"
104+
assert nodes[2].name == "Grandchild 1"
105+
assert nodes[3].name == "Grandchild 2"
106+
assert nodes[4].name == "Child 2"
107+
108+
def test_update_preserves_position(self):
109+
"""Test that updating a node doesn't change its position"""
110+
root = OrderedModel.objects.create(name="Root")
111+
child = OrderedModel.objects.create(name="Child", parent=root)
112+
113+
original_position = child.position
114+
assert original_position == 10
115+
116+
# Update the name
117+
child.name = "Updated Child"
118+
child.save()
119+
120+
# Position should remain the same
121+
assert child.position == original_position
122+
123+
def test_zero_position_is_replaced(self):
124+
"""Test that position=0 triggers auto-assignment"""
125+
root = OrderedModel.objects.create(name="Root")
126+
127+
# Even if we explicitly set position=0, it should be replaced on create
128+
child = OrderedModel(name="Child", parent=root, position=0)
129+
child.save()
130+
131+
assert child.position == 10
132+
133+
# Create another child to verify positions increment correctly
134+
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
135+
assert child2.position == 20
136+
137+
def test_ordering_inherited_from_meta(self):
138+
"""Test that ordering is inherited from OrderableTreeNode.Meta"""
139+
# This test verifies that the Meta.ordering is properly inherited
140+
root = OrderedModel.objects.create(name="Root")
141+
OrderedModel.objects.create(name="Child 2", parent=root, position=20)
142+
OrderedModel.objects.create(name="Child 1", parent=root, position=10)
143+
OrderedModel.objects.create(name="Child 3", parent=root, position=30)
144+
145+
# Query without explicit ordering should use Meta.ordering
146+
children = list(OrderedModel.objects.filter(parent=root))
147+
148+
assert children[0].name == "Child 1"
149+
assert children[1].name == "Child 2"
150+
assert children[2].name == "Child 3"

tree_queries/models.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.core.exceptions import ValidationError
22
from django.db import models
3+
from django.db.models import Max
34
from django.utils.translation import gettext_lazy as _
45

56
from tree_queries.fields import TreeNodeForeignKey
@@ -55,3 +56,48 @@ def clean(self):
5556
)
5657
):
5758
raise ValidationError(_("A node cannot be made a descendant of itself."))
59+
60+
61+
class OrderableTreeNode(TreeNode):
62+
"""
63+
A TreeNode with automatic position management for consistent sibling ordering.
64+
65+
This mixin provides automatic position value assignment when creating new nodes,
66+
ensuring siblings are properly ordered. When a node is saved without an explicit
67+
position value, it automatically receives a position 10 units higher than the
68+
maximum position among its siblings.
69+
70+
Usage:
71+
class Category(OrderableTreeNode):
72+
name = models.CharField(max_length=100)
73+
# position field and ordering are provided by OrderableTreeNode
74+
75+
The position field increments by 10 (rather than 1) to make it explicit that
76+
the position values themselves have no inherent meaning - they are purely for
77+
relative ordering, not a sibling counter or index. This approach is identical
78+
to the one used in feincms3's AbstractPage.
79+
"""
80+
81+
position = models.PositiveIntegerField(default=0, db_index=True)
82+
83+
class Meta:
84+
abstract = True
85+
ordering = ["position"]
86+
87+
def save(self, *args, **kwargs):
88+
"""
89+
Automatically assigns a position value if not set.
90+
91+
If the position is 0 (the default), calculates a new position by finding
92+
the maximum position among siblings and adding 10.
93+
"""
94+
if not self.position:
95+
self.position = 10 + (
96+
self.__class__._default_manager.filter(parent_id=self.parent_id)
97+
.order_by()
98+
.aggregate(p=Max("position"))["p"]
99+
or 0
100+
)
101+
super().save(*args, **kwargs)
102+
103+
save.alters_data = True

0 commit comments

Comments
 (0)