mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Enforce tag nesting depth and hierarchy validation
This commit is contained in:
		@@ -99,6 +99,8 @@ class Correspondent(MatchingModel):
 | 
			
		||||
 | 
			
		||||
class Tag(MatchingModel):
 | 
			
		||||
    color = models.CharField(_("color"), max_length=7, default="#a6cee3")
 | 
			
		||||
    # Maximum allowed nesting depth for tags (root = 1, max depth = 5)
 | 
			
		||||
    MAX_NESTING_DEPTH: Final[int] = 5
 | 
			
		||||
 | 
			
		||||
    is_inbox_tag = models.BooleanField(
 | 
			
		||||
        _("is inbox tag"),
 | 
			
		||||
@@ -136,9 +138,43 @@ class Tag(MatchingModel):
 | 
			
		||||
            ancestors.extend(self.parent.get_all_ancestors())
 | 
			
		||||
        return ancestors
 | 
			
		||||
 | 
			
		||||
    def subtree_height(self, node) -> int:
 | 
			
		||||
        children = list(node.children.all())
 | 
			
		||||
        if not children:
 | 
			
		||||
            return 0
 | 
			
		||||
        return 1 + max(self.subtree_height(child) for child in children)
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        # Prevent self-parenting
 | 
			
		||||
        if self.parent == self:
 | 
			
		||||
            raise ValidationError("Cannot set itself as parent.")
 | 
			
		||||
            raise ValidationError(_("Cannot set itself as parent."))
 | 
			
		||||
 | 
			
		||||
        # Prevent assigning a descendant as parent
 | 
			
		||||
        if (
 | 
			
		||||
            self.parent
 | 
			
		||||
            and self.pk is not None
 | 
			
		||||
            and any(
 | 
			
		||||
                ancestor.pk == self.pk for ancestor in self.parent.get_all_ancestors()
 | 
			
		||||
            )
 | 
			
		||||
        ):
 | 
			
		||||
            raise ValidationError(_("Cannot set parent to a descendant."))
 | 
			
		||||
 | 
			
		||||
        # Enforce maximum nesting depth
 | 
			
		||||
        new_parent_depth = 0
 | 
			
		||||
        if self.parent:
 | 
			
		||||
            new_parent_depth = len(self.parent.get_all_ancestors()) + 1
 | 
			
		||||
        if self.pk is None:
 | 
			
		||||
            # Unsaved tag cannot have children; treat as leaf
 | 
			
		||||
            height = 0
 | 
			
		||||
        else:
 | 
			
		||||
            try:
 | 
			
		||||
                height = self.subtree_height(self)
 | 
			
		||||
            except RecursionError:
 | 
			
		||||
                raise ValidationError(_("Invalid tag hierarchy."))
 | 
			
		||||
        deepest_new_depth = (new_parent_depth + 1) + height
 | 
			
		||||
        if deepest_new_depth > self.MAX_NESTING_DEPTH:
 | 
			
		||||
            raise ValidationError(_("Maximum nesting depth exceeded."))
 | 
			
		||||
 | 
			
		||||
        return super().clean()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.validators import DecimalValidator
 | 
			
		||||
from django.core.validators import MaxLengthValidator
 | 
			
		||||
from django.core.validators import RegexValidator
 | 
			
		||||
@@ -579,6 +580,30 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
 | 
			
		||||
            raise serializers.ValidationError(_("Invalid color."))
 | 
			
		||||
        return color
 | 
			
		||||
 | 
			
		||||
    def validate(self, attrs):
 | 
			
		||||
        # Validate when changing parent
 | 
			
		||||
        parent = attrs.get("parent", self.instance.parent if self.instance else None)
 | 
			
		||||
 | 
			
		||||
        if self.instance:
 | 
			
		||||
            # Temporarily set parent on the instance if updating and use model clean()
 | 
			
		||||
            original_parent = self.instance.parent
 | 
			
		||||
            try:
 | 
			
		||||
                self.instance.parent = parent
 | 
			
		||||
                self.instance.clean()
 | 
			
		||||
            except ValidationError as e:
 | 
			
		||||
                raise serializers.ValidationError({"parent": list(e)})
 | 
			
		||||
            finally:
 | 
			
		||||
                self.instance.parent = original_parent
 | 
			
		||||
        else:
 | 
			
		||||
            # For new instances, create a transient Tag and validate
 | 
			
		||||
            temp = Tag(parent=parent)
 | 
			
		||||
            try:
 | 
			
		||||
                temp.clean()
 | 
			
		||||
            except ValidationError as e:
 | 
			
		||||
                raise serializers.ValidationError({"parent": list(e)})
 | 
			
		||||
 | 
			
		||||
        return super().validate(attrs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CorrespondentField(serializers.PrimaryKeyRelatedField):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -110,3 +110,84 @@ class TestTagHierarchy(APITestCase):
 | 
			
		||||
        self.document.refresh_from_db()
 | 
			
		||||
        tags = set(self.document.tags.values_list("pk", flat=True))
 | 
			
		||||
        assert tags == {self.parent.pk, orphan.pk}
 | 
			
		||||
 | 
			
		||||
    def test_cannot_set_parent_to_self(self):
 | 
			
		||||
        tag = Tag.objects.create(name="Selfie")
 | 
			
		||||
        resp = self.client.patch(
 | 
			
		||||
            f"/api/tags/{tag.pk}/",
 | 
			
		||||
            {"parent": tag.pk},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp.status_code == 400
 | 
			
		||||
        assert "parent" in resp.data
 | 
			
		||||
 | 
			
		||||
    def test_cannot_set_parent_to_descendant(self):
 | 
			
		||||
        a = Tag.objects.create(name="A")
 | 
			
		||||
        b = Tag.objects.create(name="B", parent=a)
 | 
			
		||||
        c = Tag.objects.create(name="C", parent=b)
 | 
			
		||||
 | 
			
		||||
        # Attempt to set A's parent to C (descendant) should fail
 | 
			
		||||
        resp = self.client.patch(
 | 
			
		||||
            f"/api/tags/{a.pk}/",
 | 
			
		||||
            {"parent": c.pk},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp.status_code == 400
 | 
			
		||||
        assert "parent" in resp.data
 | 
			
		||||
 | 
			
		||||
    def test_max_depth_on_create(self):
 | 
			
		||||
        a = Tag.objects.create(name="A1")
 | 
			
		||||
        b = Tag.objects.create(name="B1", parent=a)
 | 
			
		||||
        c = Tag.objects.create(name="C1", parent=b)
 | 
			
		||||
        d = Tag.objects.create(name="D1", parent=c)
 | 
			
		||||
 | 
			
		||||
        # Creating E under D yields depth 5: allowed
 | 
			
		||||
        resp_ok = self.client.post(
 | 
			
		||||
            "/api/tags/",
 | 
			
		||||
            {"name": "E1", "parent": d.pk},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp_ok.status_code in (200, 201)
 | 
			
		||||
        e_id = (
 | 
			
		||||
            resp_ok.data["id"] if resp_ok.status_code == 201 else resp_ok.data.get("id")
 | 
			
		||||
        )
 | 
			
		||||
        assert e_id is not None
 | 
			
		||||
 | 
			
		||||
        # Creating F under E would yield depth 6: rejected
 | 
			
		||||
        resp_fail = self.client.post(
 | 
			
		||||
            "/api/tags/",
 | 
			
		||||
            {"name": "F1", "parent": e_id},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp_fail.status_code == 400
 | 
			
		||||
        assert "parent" in resp_fail.data
 | 
			
		||||
        assert any("Maximum" in str(msg) for msg in resp_fail.data["parent"])
 | 
			
		||||
 | 
			
		||||
    def test_max_depth_on_move_subtree(self):
 | 
			
		||||
        a = Tag.objects.create(name="A2")
 | 
			
		||||
        b = Tag.objects.create(name="B2", parent=a)
 | 
			
		||||
        c = Tag.objects.create(name="C2", parent=b)
 | 
			
		||||
        d = Tag.objects.create(name="D2", parent=c)
 | 
			
		||||
 | 
			
		||||
        x = Tag.objects.create(name="X2")
 | 
			
		||||
        y = Tag.objects.create(name="Y2", parent=x)
 | 
			
		||||
        assert y.parent_id == x.id
 | 
			
		||||
 | 
			
		||||
        # Moving X under D would make deepest node Y exceed depth 5 -> reject
 | 
			
		||||
        resp_fail = self.client.patch(
 | 
			
		||||
            f"/api/tags/{x.pk}/",
 | 
			
		||||
            {"parent": d.pk},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp_fail.status_code == 400
 | 
			
		||||
        assert "parent" in resp_fail.data
 | 
			
		||||
 | 
			
		||||
        # Moving X under C (depth 3) should be allowed (deepest becomes 5)
 | 
			
		||||
        resp_ok = self.client.patch(
 | 
			
		||||
            f"/api/tags/{x.pk}/",
 | 
			
		||||
            {"parent": c.pk},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        assert resp_ok.status_code in (200, 202)
 | 
			
		||||
        x.refresh_from_db()
 | 
			
		||||
        assert x.parent_id == c.id
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user