(backend) expose is_restricted field in items API

Owners activate and deactivate restriction on folders via PATCH,
gated by the restrict ability. The serializer keeps working on the
instance returned by the toggle since the item physically moves.
This commit is contained in:
Nicolas Clerc
2026-07-28 16:23:29 +02:00
parent d4505463f9
commit aa0b1daf22
10 changed files with 224 additions and 2 deletions
+1
View File
@@ -33,6 +33,7 @@ and this project adheres to
- ✨(backend) add a local entitlements backend with per-user storage limits
- ✨(frontend) add storage gauge and settings modal
- ♻️(backend) route permission decisions through a swappable backend
- ✨(backend) add restricted access on folders, detached behind a shortcut
### Fixed
+17 -2
View File
@@ -15,7 +15,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from lasuite.drf.models.choices import LinkReachChoices, get_equivalent_link_definition
from rest_framework import serializers
from rest_framework import exceptions, serializers
from core import models
from core.api import utils
@@ -250,6 +250,7 @@ class ListItemSerializer(serializers.ModelSerializer):
"is_favorite",
"link_role",
"link_reach",
"is_restricted",
"nb_accesses",
"numchild",
"numchild_folder",
@@ -282,6 +283,7 @@ class ListItemSerializer(serializers.ModelSerializer):
"creator",
"depth",
"is_favorite",
"is_restricted",
"link_role",
"link_reach",
"nb_accesses",
@@ -480,6 +482,7 @@ class ItemSerializer(ListItemSerializer):
"is_favorite",
"link_role",
"link_reach",
"is_restricted",
"nb_accesses",
"numchild",
"numchild_folder",
@@ -536,7 +539,19 @@ class ItemSerializer(ListItemSerializer):
raise NotImplementedError("Create method can not be used.")
def update(self, instance, validated_data):
"""Validate that the title is unique in the current path."""
"""Update an item, handling restriction and title uniqueness."""
is_restricted = validated_data.pop("is_restricted", None)
if is_restricted is not None and is_restricted != instance.is_restricted:
user = self.context["request"].user
if not instance.get_abilities(user).get("restrict"):
raise exceptions.PermissionDenied()
# Toggling restriction moves the item: keep working on the
# returned instance so its refreshed path is not overwritten
if is_restricted:
instance = instance.restrict(user)
else:
instance = instance.unrestrict()
if validated_data.get("title") and instance.title != validated_data.get("title"):
if instance.depth > 1:
validated_data["title"] = instance.manage_unique_title(validated_data.get("title"))
@@ -47,6 +47,7 @@ def test_api_items_children_list_anonymous_public_standalone():
"depth": 2,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -91,6 +92,7 @@ def test_api_items_children_list_anonymous_public_standalone():
"depth": 2,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -175,6 +177,7 @@ def test_api_items_children_list_anonymous_public_parent():
"depth": 4,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -213,6 +216,7 @@ def test_api_items_children_list_anonymous_public_parent():
"depth": 4,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -305,6 +309,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated
"depth": 2,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -349,6 +354,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated
"depth": 2,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -430,6 +436,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent(
"depth": 4,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -474,6 +481,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent(
"depth": 4,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -580,6 +588,7 @@ def test_api_items_children_list_authenticated_related_direct():
"depth": 2,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -624,6 +633,7 @@ def test_api_items_children_list_authenticated_related_direct():
"depth": 2,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -708,6 +718,7 @@ def test_api_items_children_list_authenticated_related_parent():
"depth": 4,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -752,6 +763,7 @@ def test_api_items_children_list_authenticated_related_parent():
"depth": 4,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -893,6 +905,7 @@ def test_api_items_children_list_authenticated_related_team_members(
"depth": 2,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -937,6 +950,7 @@ def test_api_items_children_list_authenticated_related_team_members(
"depth": 2,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -1019,6 +1033,7 @@ def test_api_items_children_list_filter_type():
"depth": 2,
"id": str(child1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -1072,6 +1087,7 @@ def test_api_items_children_list_filter_type():
"depth": 2,
"id": str(child2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -1379,6 +1395,7 @@ def test_api_items_children_list_computed_link_reach_and_role():
"depth": 3,
"id": str(item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": "restricted",
"link_role": "reader",
"numchild": 1,
@@ -1427,6 +1444,7 @@ def test_api_items_children_list_computed_link_reach_and_role():
"depth": 4,
"id": str(child.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": "public",
"link_role": "editor",
"numchild": 0,
@@ -106,6 +106,7 @@ def test_api_items_list_format():
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": item3.link_reach,
"link_role": item3.link_role,
"nb_accesses": 0,
@@ -144,6 +145,7 @@ def test_api_items_list_format():
},
"depth": 1,
"is_favorite": True,
"is_restricted": False,
"link_reach": item2.link_reach,
"link_role": item2.link_role,
"nb_accesses": 3,
@@ -182,6 +184,7 @@ def test_api_items_list_format():
},
"depth": 1,
"is_favorite": True,
"is_restricted": False,
"link_reach": item.link_reach,
"link_role": item.link_role,
"nb_accesses": 3,
@@ -0,0 +1,148 @@
"""Tests for items API endpoint: restrict / unrestrict via partial update."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_items_restrict_owner_can_activate():
"""An owner can activate restriction on a folder via partial update."""
user = factories.UserFactory()
parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
folder = factories.ItemFactory(
parent=parent,
type=models.ItemTypeChoices.FOLDER,
users=[(user, "owner")],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/items/{folder.id!s}/",
{"is_restricted": True},
format="json",
)
assert response.status_code == 200
assert response.json()["is_restricted"] is True
folder.refresh_from_db()
assert folder.is_restricted is True
assert str(folder.path) == str(folder.id)
assert folder.shortcut.type == models.ItemTypeChoices.SHORTCUT
assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").count() == 1
def test_api_items_restrict_non_owner_cannot_activate():
"""A non-owner cannot activate restriction on a folder."""
user = factories.UserFactory()
parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
folder = factories.ItemFactory(
parent=parent,
type=models.ItemTypeChoices.FOLDER,
users=[(user, "administrator")],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/items/{folder.id!s}/",
{"is_restricted": True},
format="json",
)
assert response.status_code == 403
folder.refresh_from_db()
assert folder.is_restricted is False
def test_api_items_restrict_owner_cannot_activate_a_root():
"""A root folder cannot be restricted, even by its owner."""
user = factories.UserFactory()
folder = factories.ItemFactory(
type=models.ItemTypeChoices.FOLDER,
users=[(user, "owner")],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/items/{folder.id!s}/",
{"is_restricted": True},
format="json",
)
assert response.status_code == 403
folder.refresh_from_db()
assert folder.is_restricted is False
def test_api_items_restrict_owner_can_deactivate():
"""An owner can deactivate restriction on a folder via partial update."""
user = factories.UserFactory()
parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER)
folder = folder.restrict(user)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/items/{folder.id!s}/",
{"is_restricted": False},
format="json",
)
assert response.status_code == 200
assert response.json()["is_restricted"] is False
folder.refresh_from_db()
assert folder.is_restricted is False
assert str(folder.path) == f"{parent.id!s}.{folder.id!s}"
assert not models.Item.objects.filter(target=folder).exists()
def test_api_items_restrict_excluded_owner_cannot_deactivate():
"""A user without explicit access cannot deactivate a restricted folder."""
parent_owner = factories.UserFactory()
user = factories.UserFactory()
parent = factories.ItemFactory(
type=models.ItemTypeChoices.FOLDER,
users=[(parent_owner, "owner")],
)
folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER)
folder = folder.restrict(user)
client = APIClient()
client.force_login(parent_owner)
response = client.patch(
f"/api/v1.0/items/{folder.id!s}/",
{"is_restricted": False},
format="json",
)
assert response.status_code == 403
folder.refresh_from_db()
assert folder.is_restricted is True
def test_api_items_restrict_response_includes_field():
"""The is_restricted field is present in the API response."""
user = factories.UserFactory()
folder = factories.ItemFactory(
type=models.ItemTypeChoices.FOLDER,
users=[(user, "owner")],
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/items/{folder.id!s}/")
assert response.status_code == 200
assert "is_restricted" in response.json()
assert response.json()["is_restricted"] is False
@@ -43,6 +43,7 @@ def test_api_items_retrieve_anonymous_public_standalone():
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "public",
"link_role": item.link_role,
"nb_accesses": 0,
@@ -100,6 +101,7 @@ def test_api_items_retrieve_anonymous_public_parent():
},
"depth": 3,
"is_favorite": False,
"is_restricted": False,
"link_reach": item.link_reach,
"link_role": item.link_role,
"nb_accesses": 0,
@@ -204,6 +206,7 @@ def test_api_items_retrieve_authenticated_unrelated_public_or_authenticated(reac
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": reach,
"link_role": item.link_role,
"nb_accesses": 0,
@@ -267,6 +270,7 @@ def test_api_items_retrieve_authenticated_public_or_authenticated_parent(reach):
},
"depth": 3,
"is_favorite": False,
"is_restricted": False,
"link_reach": item.link_reach,
"link_role": item.link_role,
"nb_accesses": 0,
@@ -408,6 +412,7 @@ def test_api_items_retrieve_authenticated_related_direct():
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": item.link_reach,
"link_role": item.link_role,
"nb_accesses": 2,
@@ -475,6 +480,7 @@ def test_api_items_retrieve_authenticated_related_parent():
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
"is_favorite": False,
"is_restricted": False,
"link_reach": "restricted",
"link_role": item.link_role,
"nb_accesses": 2,
@@ -652,6 +658,7 @@ def test_api_items_retrieve_authenticated_related_team_members(teams, role, mock
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "restricted",
"link_role": item.link_role,
"nb_accesses": 5,
@@ -727,6 +734,7 @@ def test_api_items_retrieve_authenticated_related_team_administrators(teams, rol
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "restricted",
"link_role": item.link_role,
"nb_accesses": 5,
@@ -802,6 +810,7 @@ def test_api_items_retrieve_authenticated_related_team_owners(teams, mock_user_t
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "restricted",
"link_role": item.link_role,
"nb_accesses": 5,
@@ -1201,6 +1210,7 @@ def test_api_items_retrieve_file_with_url_property(upload_state):
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "public",
"link_role": item.link_role,
"nb_accesses": 1,
@@ -1274,6 +1284,7 @@ def test_api_items_retrieve_file_with_url_property_non_previewable(upload_state)
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "public",
"link_role": item.link_role,
"nb_accesses": 1,
@@ -1337,6 +1348,7 @@ def test_api_items_retrieve_file_with_url_property_with_spaces():
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"link_reach": "public",
"link_role": item.link_role,
"nb_accesses": 1,
@@ -1482,6 +1494,7 @@ def test_api_items_retrieve_file_analysing_not_creator():
},
"depth": 1,
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": "public",
"link_role": item.link_role,
@@ -72,6 +72,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(top_parent.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": top_parent.link_reach,
"link_role": top_parent.link_role,
@@ -111,6 +112,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(parent.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": parent.link_reach,
"link_role": parent.link_role,
@@ -139,6 +141,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(top_parent.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": top_parent.link_reach,
"link_role": top_parent.link_role,
@@ -189,6 +192,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(children.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": children.link_reach,
"link_role": children.link_role,
@@ -217,6 +221,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(top_parent.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": top_parent.link_reach,
"link_role": top_parent.link_role,
@@ -255,6 +260,7 @@ def test_api_items_search_authenticated_without_filters():
"hard_delete_at": None,
"id": str(parent.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": parent.link_reach,
"link_role": parent.link_role,
@@ -82,6 +82,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings):
"hard_delete_at": None,
"id": str(item_b.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": item_b.link_reach,
"link_role": item_b.link_role,
@@ -119,6 +120,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings):
"filename": None,
"hard_delete_at": None,
"id": str(folder.id),
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": folder.link_reach,
"link_role": folder.link_role,
@@ -159,6 +161,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings):
"hard_delete_at": None,
"id": str(item_c.id),
"is_favorite": False,
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": item_c.link_reach,
"link_role": item_c.link_role,
@@ -196,6 +199,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings):
"filename": None,
"hard_delete_at": None,
"id": str(folder.id),
"is_restricted": False,
"is_wopi_supported": False,
"link_reach": folder.link_reach,
"link_role": folder.link_role,
@@ -103,6 +103,7 @@ def test_api_items_trashbin_format(settings):
"size": None,
"description": None,
"hard_delete_at": ((now + timedelta(days=30)).isoformat()),
"is_restricted": False,
"is_wopi_supported": False,
}
@@ -158,6 +158,7 @@ def test_items_api_anonymous_to_a_public_tree_structure():
"depth": 3,
"id": str(level2_1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level2_1.link_reach,
"link_role": level2_1.link_role,
"nb_accesses": 0,
@@ -197,6 +198,7 @@ def test_items_api_anonymous_to_a_public_tree_structure():
"depth": 3,
"id": str(level2_2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level2_2.link_reach,
"link_role": level2_2.link_role,
"nb_accesses": 0,
@@ -230,6 +232,7 @@ def test_items_api_anonymous_to_a_public_tree_structure():
"depth": 2,
"id": str(level1_2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level1_2.link_reach,
"link_role": level1_2.link_role,
"nb_accesses": 0,
@@ -389,6 +392,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 3,
"id": str(level2_1.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level2_1.item.link_reach,
"link_role": level2_1.item.link_role,
"nb_accesses": 3,
@@ -437,6 +441,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 4,
"id": str(level3_1.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level3_1.item.link_reach,
"link_role": level3_1.item.link_role,
"nb_accesses": 4,
@@ -475,6 +480,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 3,
"id": str(level2_2.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level2_2.item.link_reach,
"link_role": level2_2.item.link_role,
"nb_accesses": 3,
@@ -508,6 +514,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 2,
"id": str(level1_1.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level1_1.item.link_reach,
"link_role": level1_1.item.link_role,
"nb_accesses": 2,
@@ -547,6 +554,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 2,
"id": str(level1_2.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level1_2.item.link_reach,
"link_role": level1_2.item.link_role,
"nb_accesses": 2,
@@ -586,6 +594,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 2,
"id": str(level1_3.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": level1_3.item.link_reach,
"link_role": level1_3.item.link_role,
"nb_accesses": 2,
@@ -619,6 +628,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries):
"depth": 1,
"id": str(root.item.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": root.item.link_reach,
"link_role": root.item.link_role,
"nb_accesses": 1,
@@ -735,6 +745,7 @@ def test_api_items_tree_authenticated_with_access_authenticated():
"depth": 2,
"id": str(level1_1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": "authenticated",
"link_role": level1_1.link_role,
"nb_accesses": 0,
@@ -779,6 +790,7 @@ def test_api_items_tree_authenticated_with_access_authenticated():
"depth": 3,
"id": str(level2_1.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": "authenticated",
"link_role": level2_1.link_role,
"nb_accesses": 0,
@@ -818,6 +830,7 @@ def test_api_items_tree_authenticated_with_access_authenticated():
"depth": 3,
"id": str(level2_2.id),
"is_favorite": False,
"is_restricted": False,
"link_reach": "authenticated",
"link_role": level2_2.link_role,
"nb_accesses": 0,