Browse Source

Management command repairing the thread category tree (#983)

* Management command repairing the thread category tree

A new management command `fixcategoriestree` can now fix broken
category hierarchy. This problem can occur when adding or deleting
category data directly in the database causing the tree’s left/right
values (nested sets) to not properly align.

* PR cleanup

* PR tweaks 2
Einar Forselv 7 years ago
parent
commit
9f2f78d232

+ 23 - 0
misago/categories/management/commands/fixcategoriestree.py

@@ -0,0 +1,23 @@
+from django.core.management.base import BaseCommand
+
+from misago.acl import version as acl_version
+from misago.categories.models import Category
+
+
+class Command(BaseCommand):
+    """
+    This command rebuilds the thread category tree.
+    It can be useful when the category hierarchy is corrupt due to modifying directly
+    in the database causing MPTT's nested sets to not align correctly.
+    A typical case is when injecting default data into the database from outside misago.
+    """
+    help = 'Rebuilds the thread category tree'
+
+    def handle(self, *args, **options):
+        root = Category.objects.root_category()
+        Category.objects.partial_rebuild(root.tree_id)
+        self.stdout.write("Categories tree has been rebuild.")
+
+        Category.objects.clear_cache()
+        acl_version.invalidate()
+        self.stdout.write("Caches have been cleared.")

+ 83 - 0
misago/categories/tests/test_fixcategoriestree.py

@@ -0,0 +1,83 @@
+from django.core.management import call_command
+from django.test import TestCase
+from django.utils.six import StringIO
+
+from misago.categories.management.commands import fixcategoriestree
+from misago.categories.models import Category
+
+
+def run_command():
+    """Run the management command"""
+    command = fixcategoriestree.Command()
+    out = StringIO()
+    call_command(command, stdout=out)
+
+
+class FixCategoriesTreeTests(TestCase):
+    """
+    The purpose is the verify that the management command
+    fixes the lft/rght values of the thread category tree.
+    """
+    def setUp(self):
+        Category.objects.create(name='Test', slug='test', parent=Category.objects.root_category())
+        self.fetch_categories()
+
+    def assertValidTree(self, expected_tree):
+        root = Category.objects.root_category()
+        queryset = Category.objects.filter(tree_id=root.tree_id).order_by('lft')
+
+        current_tree = []
+        for category in queryset:
+            current_tree.append((category, category.get_level(), category.lft, category.rght))
+
+        for i, category in enumerate(expected_tree):
+            _category = current_tree[i]
+            if category[0] != _category[0]:
+                self.fail(('expected category at index #%s to be %s, '
+                           'found %s instead') % (i, category[0], _category[0]))
+            if category[1] != _category[1]:
+                self.fail(('expected level at index #%s to be %s, '
+                           'found %s instead') % (i, category[1], _category[1]))
+            if category[2] != _category[2]:
+                self.fail(('expected lft at index #%s to be %s, '
+                           'found %s instead') % (i, category[2], _category[2]))
+            if category[3] != _category[3]:
+                self.fail(('expected lft at index #%s to be %s, '
+                           'found %s instead') % (i, category[3], _category[3]))
+
+    def fetch_categories(self):
+        """gets a fresh version from the database"""
+        self.root = Category.objects.root_category()
+        self.first_category = Category.objects.get(slug='first-category')
+        self.test_category = Category.objects.get(slug='test')
+
+    def test_fix_categories_tree_unaffected(self):
+        """Command should not affect a healthy three"""
+        tree_id = self.root.tree_id
+        run_command()
+
+        self.fetch_categories()
+
+        self.assertValidTree([
+            (self.root, 0, 1, 6),
+            (self.first_category, 1, 2, 3),
+            (self.test_category, 1, 4, 5),
+        ])
+
+        self.assertEqual(self.root.tree_id, tree_id, msg="tree_id changed by command")
+
+    def test_fix_categories_tree_affected(self):
+        """Command should fix a broken tree"""
+        # Root node with too narrow lft/rght range
+        Category.objects.filter(id=self.root.id).update(lft=1, rght=4)
+        # Make conflicting/identical lft/rght range
+        Category.objects.filter(id=self.test_category.id).update(lft=2, rght=3)
+
+        run_command()
+        self.fetch_categories()
+
+        self.assertValidTree([
+            (self.root, 0, 1, 6),
+            (self.test_category, 1, 2, 3),
+            (self.first_category, 1, 4, 5),
+        ])