mirror of
https://github.com/suitenumerique/drive.git
synced 2026-08-17 20:15:40 +02:00
✨(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:
@@ -1545,6 +1545,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}"
|
||||
@@ -1559,6 +1583,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)}
|
||||
|
||||
Reference in New Issue
Block a user