From f4e33b3db24fcf5d1c30db4d28b1c4c2172ba1fe Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 11:01:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20activate=20restriction=20b?= =?UTF-8?q?y=20moving=20the=20folder=20to=20the=20tree=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restriction is structural: the folder physically leaves its parent so inheritance stops applying without any query-level cut. A shortcut materializes its origin location. Explicit link reach is kept and defaults to restricted only when it was inherited. --- src/backend/core/models.py | 92 +++++++ .../tests/test_models_items_restricted.py | 224 ++++++++++++++++++ .../core/tests/test_models_items_shortcuts.py | 12 + 3 files changed, 328 insertions(+) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 921b46bc..f2655f25 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1541,6 +1541,30 @@ class Item(TreeModel, BaseModel): } ) + if self.is_restricted: + raise ValidationError( + { + "target": ValidationError( + _("A restricted folder cannot be moved"), + code="item_move_restricted", + ) + } + ) + + if ( + self.type == ItemTypeChoices.SHORTCUT + and target + and str(target.path).startswith(str(self.target.path)) + ): + raise ValidationError( + { + "target": ValidationError( + _("A shortcut cannot be moved under its own target"), + code="item_move_shortcut_under_target", + ) + } + ) + old_path = self.path if target: self.path = f"{target.path!s}.{self.id!s}" @@ -1555,6 +1579,74 @@ class Item(TreeModel, BaseModel): path=RawSQL("%s || subpath(path, nlevel(%s))", (str(self.path), str(old_path))) ) + @transaction.atomic + def restrict(self, user): + """Restrict the folder by detaching it to the tree root behind a shortcut.""" + item = self._meta.model.objects.select_for_update().get(pk=self.pk) + + if item.type != ItemTypeChoices.FOLDER: + raise ValidationError( + { + "is_restricted": ValidationError( + _("Only folders can be restricted"), + code="item_restrict_type_folder_only", + ) + } + ) + if item.is_restricted: + raise ValidationError( + { + "is_restricted": ValidationError( + _("This folder is already restricted"), + code="item_restrict_already_restricted", + ) + } + ) + if item.depth == 1: + raise ValidationError( + { + "is_restricted": ValidationError( + _("A root folder cannot be restricted"), + code="item_restrict_root", + ) + } + ) + if item.ancestors_deleted_at: + raise ValidationError( + { + "is_restricted": ValidationError( + _("A deleted folder cannot be restricted"), + code="item_restrict_deleted", + ) + } + ) + + parent = item.parent() + + ItemAccess.objects.update_or_create( + item=item, user=user, defaults={"role": RoleChoices.OWNER} + ) + + # The move must run while the flag is still unset: move() refuses + # restricted folders + item.move(None) + + item.is_restricted = True + if item.link_reach is None: + item.link_reach = LinkReachChoices.RESTRICTED + item.save(update_fields=["is_restricted", "link_reach"]) + + self._meta.model.objects.create_child( + parent=parent, + creator=user, + type=ItemTypeChoices.SHORTCUT, + target=item, + title=item.title, + ) + item.invalidate_nb_accesses_cache() + + return item + class MirrorItemTask(BaseModel): """Model managing a status for a mirroring task.""" diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index 85535fd3..14bc0535 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError import pytest +from lasuite.drf.models.choices import LinkReachChoices from core import factories, models @@ -93,3 +94,226 @@ def test_models_items_restricted_file_cannot_be_restricted(): """A file cannot be restricted.""" with pytest.raises(ValidationError): factories.ItemFactory(type=models.ItemTypeChoices.FILE, is_restricted=True) + + +def test_models_items_restricted_restrict_moves_folder_to_root(): + """Activating restriction moves the folder and its subtree to the tree root.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + + folder.restrict(user) + + folder.refresh_from_db() + child.refresh_from_db() + assert str(folder.path) == str(folder.id) + assert str(child.path) == f"{folder.id!s}.{child.id!s}" + + +def test_models_items_restricted_restrict_creates_shortcut(): + """Activating restriction materializes the origin location with a shortcut.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + folder.restrict(user) + + folder.refresh_from_db() + shortcut = folder.shortcut + assert shortcut.type == models.ItemTypeChoices.SHORTCUT + assert str(shortcut.path) == f"{parent.id!s}.{shortcut.id!s}" + assert shortcut.title == folder.title + assert shortcut.creator == user + + +def test_models_items_restricted_restrict_sets_flag_and_creates_owner_access(): + """Activating restriction sets is_restricted and creates an explicit owner access.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + assert not models.ItemAccess.objects.filter(item=folder, user=user).exists() + + folder = folder.restrict(user) + + assert folder.is_restricted is True + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + +def test_models_items_restricted_restrict_keeps_existing_explicit_access(): + """Activating restriction does not duplicate an existing explicit owner access.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(item=folder, user=user, role="owner") + + folder.restrict(user) + + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").count() == 1 + + +def test_models_items_restricted_restrict_promotes_existing_lower_access(): + """Activating restriction promotes an existing lower explicit access to owner.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + access = factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + folder.restrict(user) + + access.refresh_from_db() + assert access.role == models.RoleChoices.OWNER + + +def test_models_items_restricted_restrict_defaults_link_reach(): + """Activating restriction sets link reach to restricted when none is explicit.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + link_role="reader", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + + folder = folder.restrict(user) + + assert folder.link_reach == LinkReachChoices.RESTRICTED + + +def test_models_items_restricted_restrict_keeps_explicit_link_reach(): + """Activating restriction keeps an existing explicit link reach.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.AUTHENTICATED, + ) + + folder = folder.restrict(user) + + assert folder.link_reach == LinkReachChoices.AUTHENTICATED + + +def test_models_items_restricted_restrict_requires_a_folder(): + """Only folders can be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + item = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FILE) + + with pytest.raises(ValidationError, match="Only folders can be restricted"): + item.restrict(user) + + +def test_models_items_restricted_restrict_rejects_already_restricted(): + """A restricted folder cannot be restricted again.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder.restrict(user) + folder.refresh_from_db() + + with pytest.raises(ValidationError, match="This folder is already restricted"): + folder.restrict(user) + + +def test_models_items_restricted_restrict_rejects_roots(): + """A root folder cannot be restricted.""" + user = factories.UserFactory() + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + with pytest.raises(ValidationError, match="A root folder cannot be restricted"): + folder.restrict(user) + + +def test_models_items_restricted_restrict_rejects_deleted(): + """A deleted folder cannot be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + parent.soft_delete() + folder.refresh_from_db() + + with pytest.raises(ValidationError, match="A deleted folder cannot be restricted"): + folder.restrict(user) + + +def test_models_items_restricted_cuts_role_inheritance(): + """Roles inherited from former ancestors stop applying once restricted.""" + parent_user = factories.UserFactory() + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_user, models.RoleChoices.OWNER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + folder = folder.restrict(user) + + assert folder.get_role(parent_user) is None + assert folder.get_role(user) == models.RoleChoices.OWNER + + +def test_models_items_restricted_descendants_inherit_from_restricted_folder(): + """Descendants inherit the explicit accesses of the restricted folder only.""" + parent_user = factories.UserFactory() + user = factories.UserFactory() + reader = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_user, models.RoleChoices.OWNER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + factories.UserItemAccessFactory(item=folder, user=reader, role="reader") + + folder.restrict(user) + + child.refresh_from_db() + assert child.get_role(reader) == models.RoleChoices.READER + assert child.get_role(parent_user) is None + + +def test_models_items_restricted_cuts_link_inheritance(): + """The link definition of former ancestors stops applying once restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + link_role="editor", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + + folder = folder.restrict(user) + + child.refresh_from_db() + assert folder.computed_link_definition == { + "link_reach": LinkReachChoices.RESTRICTED, + "link_role": None, + } + assert child.computed_link_definition == { + "link_reach": LinkReachChoices.RESTRICTED, + "link_role": None, + } + + +def test_models_items_restricted_move_rejects_restricted_roots(): + """A restricted folder cannot be moved: it must be deactivated first.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + other = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + with pytest.raises(ValidationError, match="A restricted folder cannot be moved"): + folder.move(other) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index bb8c1f4c..6580f204 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -51,6 +51,18 @@ def test_models_items_shortcuts_deleted_with_their_target(): assert not models.Item.objects.filter(pk=shortcut.pk).exists() +def test_models_items_shortcuts_move_rejects_own_target_subtree(): + """A shortcut cannot be moved under the subtree of its own target.""" + shortcut = factories.ShortcutFactory() + folder = factories.ItemFactory( + parent=shortcut.target, + type=models.ItemTypeChoices.FOLDER, + ) + + with pytest.raises(ValidationError, match="cannot be moved under its own target"): + shortcut.move(folder) + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)}