(backend) activate restriction by moving the folder to the tree root

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.
This commit is contained in:
Nicolas Clerc
2026-07-28 16:23:29 +02:00
parent 052193c55c
commit f4e33b3db2
3 changed files with 328 additions and 0 deletions
+92
View File
@@ -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."""
@@ -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)
@@ -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)}