From 99d3a4d20b8d151b5f22218e534cd028950a87b3 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:32:49 +0200 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8(backend)=20expose=20the=20can?= =?UTF-8?q?=5Fupload=20reason=20as=20the=20API=20error=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload gates returned a generic "permission_denied" code whatever the entitlements backend's refusal reason. Forwarding the backend's reason as the DRF error code lets the frontend map each rejection to a specific, translatable message instead of a catch-all one. --- src/backend/core/api/viewsets.py | 6 ++++-- .../core/tests/items/test_api_item_upload_ended.py | 13 +++++++++---- .../tests/items/test_api_items_children_create.py | 9 +++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 54bbbe335..9bef2a1f0 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -800,7 +800,8 @@ def upload_ended(self, request, *args, **kwargs): if not can_upload["result"]: self._complete_item_deletion(item) raise drf.exceptions.PermissionDenied( - detail=can_upload.get("message", "You do not have permission to upload files.") + detail=can_upload.get("message", "You do not have permission to upload files."), + code=can_upload.get("reason"), ) s3_client = default_storage.connection.meta.client @@ -1089,7 +1090,8 @@ def children(self, request, *args, **kwargs): and not can_upload["result"] ): raise drf.exceptions.PermissionDenied( - detail=can_upload.get("message", "You do not have permission to upload files.") + detail=can_upload.get("message", "You do not have permission to upload files."), + code=can_upload.get("reason"), ) extension = serializer.validated_data.pop("extension", None) diff --git a/src/backend/core/tests/items/test_api_item_upload_ended.py b/src/backend/core/tests/items/test_api_item_upload_ended.py index 0594d47ae..95519a282 100644 --- a/src/backend/core/tests/items/test_api_item_upload_ended.py +++ b/src/backend/core/tests/items/test_api_item_upload_ended.py @@ -178,17 +178,22 @@ def test_api_item_upload_ended_empty_file(): assert response.json()["mimetype"] == "application/x-empty" +@pytest.mark.parametrize("reason", [None, "user_quota_excedeed"]) @mock.patch("core.api.viewsets.get_entitlements_backend") def test_api_item_upload_ended_entitlements_backend_returns_falsy( - mock_get_entitlements_backend, + mock_get_entitlements_backend, reason ): """ Test that the API returns a 403 when the entitlements backend returns a falsy result. - It should hard delete the item. + It should hard delete the item. When the backend gives a reason, it is exposed as + the error code so the frontend can show a specific, translatable message. """ # Mock the entitlement backend to return a falsy result mock_entitlement_backend = mock.Mock() - mock_entitlement_backend.can_upload.return_value = {"result": False} + return_value = {"result": False} + if reason: + return_value["reason"] = reason + mock_entitlement_backend.can_upload.return_value = return_value mock_get_entitlements_backend.return_value = mock_entitlement_backend user = factories.UserFactory() @@ -210,7 +215,7 @@ def test_api_item_upload_ended_entitlements_backend_returns_falsy( "type": "client_error", "errors": [ { - "code": "permission_denied", + "code": reason or "permission_denied", "detail": "You do not have permission to upload files.", "attr": None, } diff --git a/src/backend/core/tests/items/test_api_items_children_create.py b/src/backend/core/tests/items/test_api_items_children_create.py index fe66245e6..e3a8dc28f 100644 --- a/src/backend/core/tests/items/test_api_items_children_create.py +++ b/src/backend/core/tests/items/test_api_items_children_create.py @@ -544,13 +544,16 @@ def create_item(): assert response2.status_code == 201 +@pytest.mark.parametrize("reason", [None, "user_quota_excedeed"]) @pytest.mark.parametrize("message", [None, "Hello World"]) @mock.patch("core.api.viewsets.get_entitlements_backend") def test_api_items_children_create_entitlements_backend_returns_falsy( - mock_get_entitlements_backend, message + mock_get_entitlements_backend, message, reason ): """ Test that the API returns a 403 when the entitlements backend returns a falsy result. + When the backend gives a reason, it is exposed as the error code so the frontend + can show a specific, translatable message. """ # Mock the entitlement backend to return a falsy result @@ -558,6 +561,8 @@ def test_api_items_children_create_entitlements_backend_returns_falsy( return_value = {"result": False} if message: return_value["message"] = message + if reason: + return_value["reason"] = reason mock_entitlement_backend.can_upload.return_value = return_value mock_get_entitlements_backend.return_value = mock_entitlement_backend @@ -581,7 +586,7 @@ def test_api_items_children_create_entitlements_backend_returns_falsy( "type": "client_error", "errors": [ { - "code": "permission_denied", + "code": reason or "permission_denied", "detail": message or "You do not have permission to upload files.", "attr": None, } From d9cfb4dae16eb11a63e9841cd78c480036eced28 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:33:39 +0200 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8(backend)=20expose=20the=20user?= =?UTF-8?q?=20quota=20in=20the=20entitlements=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend needs the storage usage and limit to render a quota gauge. Entitlements backends can now return a quota (usage/limit, locked when the organization quota is reached, or an explicit error state) and the DeployCenter backend derives the can_upload reason from the resolve level when the service does not provide one, so the gauge and the upload errors stay consistent. --- src/backend/core/api/viewsets.py | 3 + .../core/entitlements/backends/base.py | 36 +++ .../entitlements/backends/deploycenter.py | 82 +++++- .../test_api_entitlements_deploycenter.py | 252 +++++++++++++++++- 4 files changed, 366 insertions(+), 7 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 9bef2a1f0..84a483bb5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2445,5 +2445,8 @@ def list(self, request): method = getattr(entitlements_backend, method_name) if callable(method): entitlements[method_name] = method(request.user) + quota = entitlements_backend.get_quota(request.user) + if quota: + entitlements["quota"] = quota entitlements["context"] = entitlements_backend.get_context(request.user) return drf.response.Response(entitlements) diff --git a/src/backend/core/entitlements/backends/base.py b/src/backend/core/entitlements/backends/base.py index 77f373659..914e9224e 100644 --- a/src/backend/core/entitlements/backends/base.py +++ b/src/backend/core/entitlements/backends/base.py @@ -1,6 +1,38 @@ """Entitlements Backend base class.""" from abc import ABC, abstractmethod +from enum import StrEnum + + +class QuotaState(StrEnum): + """State of a quota gauge returned by get_quota.""" + + DEFAULT = "default" + EXCEDEED_LOCKED = "excedeed_locked" + ERROR = "error" + + +class QuotaReason(StrEnum): + """Reasons explaining why the quota gauge is locked (get_quota output).""" + + ORGANIZATION_QUOTA_EXCEDEED = "organization_quota_excedeed" + + +class CanUploadReason(StrEnum): + """Reasons explaining why a user cannot upload (can_upload output).""" + + NO_ORGANIZATION = "no_organization" + NOT_ACTIVATED = "not_activated" + USER_QUOTA_EXCEDEED = "user_quota_excedeed" + USER_OVERRIDE_QUOTA_EXCEDEED = "user_override_quota_excedeed" + ORGANIZATION_QUOTA_EXCEDEED = "organization_quota_excedeed" + + +class QuotaError(StrEnum): + """Errors that can occur while computing a quota.""" + + METRIC_ACCOUNT_NOT_FOUND = "metric_account_not_found" + MAX_STORAGE_ACCOUNT_NOT_FOUND = "max_storage_account_not_found" class EntitlementsBackend(ABC): @@ -21,3 +53,7 @@ def can_upload(self, user): def get_context(self, user): # pylint: disable=unused-argument """Get context for a user.""" return {} + + def get_quota(self, user): # pylint: disable=unused-argument + """Get quota for a user.""" + return {} diff --git a/src/backend/core/entitlements/backends/deploycenter.py b/src/backend/core/entitlements/backends/deploycenter.py index 88f91806f..899ea1d7c 100644 --- a/src/backend/core/entitlements/backends/deploycenter.py +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -6,11 +6,16 @@ import requests -from core.entitlements.backends.base import EntitlementsBackend +from core.entitlements.backends.base import ( + CanUploadReason, + EntitlementsBackend, + QuotaError, + QuotaReason, + QuotaState, +) logger = logging.getLogger(__name__) -ENTITLEMENTS_CACHE_TIMEOUT = 60 ENTITLEMENTS_CACHE_KEY_PREFIX = "entitlements:user:" @@ -72,12 +77,81 @@ def get_context(self, user): def can_upload(self, user): """Check if a user can upload a file.""" entitlements = self.get_entitlements(user) + result = entitlements.get("entitlements", {}).get("can_upload", False) + reason = entitlements.get("entitlements", {}).get("can_upload_reason", None) + resolve_level = entitlements.get("entitlements", {}).get("can_upload_resolve_level", None) + + actual_reason = reason + if not actual_reason and not result: + if resolve_level == "user": + actual_reason = CanUploadReason.USER_QUOTA_EXCEDEED + elif resolve_level == "user_override": + actual_reason = CanUploadReason.USER_OVERRIDE_QUOTA_EXCEDEED + elif resolve_level == "organization": + actual_reason = CanUploadReason.ORGANIZATION_QUOTA_EXCEDEED + return { - "result": entitlements.get("entitlements", {}).get("can_upload", False), - "reason": entitlements.get("entitlements", {}).get("can_upload_reason", None), + "result": result, + "reason": actual_reason, } def can_access(self, user): """Check if a user can access the app.""" entitlements = self.get_entitlements(user) return {"result": entitlements.get("entitlements", {}).get("can_access", False)} + + def get_quota(self, user): + """Get quota for a user.""" + if not user.is_authenticated: + return {} + + entitlements = self.get_entitlements(user) + can_upload = entitlements.get("entitlements", {}).get("can_upload", False) + can_upload_resolve_level = entitlements.get("entitlements", {}).get( + "can_upload_resolve_level", False + ) + can_upload_reason = entitlements.get("entitlements", {}).get("can_upload_reason", None) + + # Means that the service is not enabled in the user's organization or + # the user does not have organization. + # Do not render the gauge. + if not can_upload and can_upload_reason in [ + CanUploadReason.NO_ORGANIZATION, + CanUploadReason.NOT_ACTIVATED, + ]: + return {} + + max_storage_organization = entitlements.get("entitlements", {}).get( + "max_storage_organization", {} + ) + # Means that the user's organization has reached the quota. + if ( + not can_upload + and max_storage_organization + and can_upload_resolve_level == "organization" + ): + return { + "state": QuotaState.EXCEDEED_LOCKED, + "reason": QuotaReason.ORGANIZATION_QUOTA_EXCEDEED, + } + + metric_account = entitlements.get("metrics", {}).get("account", {}) + max_storage_account = entitlements.get("entitlements", {}).get("max_storage_account") + + if not metric_account: + return { + "state": QuotaState.ERROR, + "error": QuotaError.METRIC_ACCOUNT_NOT_FOUND, + } + + if max_storage_account is None: + return { + "state": QuotaState.ERROR, + "error": QuotaError.MAX_STORAGE_ACCOUNT_NOT_FOUND, + } + + return { + "state": QuotaState.DEFAULT, + "usage": metric_account.get("storage_used", 0), + "limit": max_storage_account, + } diff --git a/src/backend/core/tests/test_api_entitlements_deploycenter.py b/src/backend/core/tests/test_api_entitlements_deploycenter.py index 280335b94..dc4556ca9 100644 --- a/src/backend/core/tests/test_api_entitlements_deploycenter.py +++ b/src/backend/core/tests/test_api_entitlements_deploycenter.py @@ -59,7 +59,13 @@ def test_api_entitlements_deploycenter_get_entitlements_both_true(): "entitlements": { "can_access": True, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -77,6 +83,11 @@ def test_api_entitlements_deploycenter_get_entitlements_both_true(): "result": True, "reason": None, }, + "quota": { + "state": "default", + "usage": 25000000, + "limit": 100000000, + }, "context": { "organization": None, "operator": None, @@ -159,7 +170,13 @@ def test_api_entitlements_deploycenter_get_entitlements_can_access_false(): "entitlements": { "can_access": False, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -177,6 +194,11 @@ def test_api_entitlements_deploycenter_get_entitlements_can_access_false(): "result": True, "reason": None, }, + "quota": { + "state": "default", + "usage": 25000000, + "limit": 100000000, + }, "context": { "organization": None, "operator": None, @@ -208,7 +230,13 @@ def test_api_entitlements_deploycenter_get_entitlements_cache(): "entitlements": { "can_access": True, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -226,6 +254,11 @@ def test_api_entitlements_deploycenter_get_entitlements_cache(): "result": True, "reason": None, }, + "quota": { + "state": "default", + "usage": 25000000, + "limit": 100000000, + }, "context": { "organization": None, "operator": None, @@ -252,6 +285,11 @@ def test_api_entitlements_deploycenter_get_entitlements_cache(): "result": True, "reason": None, }, + "quota": { + "state": "default", + "usage": 25000000, + "limit": 100000000, + }, "context": { "organization": None, "operator": None, @@ -262,6 +300,214 @@ def test_api_entitlements_deploycenter_get_entitlements_cache(): assert len(responses.calls) == 1 +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +@pytest.mark.parametrize("reason", ["no_organization", "not_activated"]) +def test_api_entitlements_deploycenter_quota_hidden(reason): + """The quota gauge should be hidden when the service is not usable by the user.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": False, + "can_upload_reason": reason, + } + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert "quota" not in response.json() + assert response.json()["can_upload"] == { + "result": False, + "reason": reason, + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_quota_organization_excedeed(): + """The quota gauge should be locked when the organization quota is reached.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": False, + "can_upload_resolve_level": "organization", + "max_storage_organization": 100000000, + } + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["quota"] == { + "state": "excedeed_locked", + "reason": "organization_quota_excedeed", + } + assert response.json()["can_upload"] == { + "result": False, + "reason": "organization_quota_excedeed", + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_quota_error_metric_account_not_found(): + """The quota gauge should be in error when the account metrics are missing.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": True, + "max_storage_account": 100000000, + } + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["quota"] == { + "state": "error", + "error": "metric_account_not_found", + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_quota_error_max_storage_account_not_found(): + """The quota gauge should be in error when the account storage limit is missing.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": True, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["quota"] == { + "state": "error", + "error": "max_storage_account_not_found", + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +@pytest.mark.parametrize( + "resolve_level,expected_reason", + [ + ("user", "user_quota_excedeed"), + ("user_override", "user_override_quota_excedeed"), + ("organization", "organization_quota_excedeed"), + ], +) +def test_api_entitlements_deploycenter_can_upload_reason_from_resolve_level( + resolve_level, expected_reason +): + """When no explicit reason is given, it should be derived from the resolve level.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": False, + "can_upload_resolve_level": resolve_level, + } + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == { + "result": False, + "reason": expected_reason, + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_can_upload_explicit_reason_wins(): + """An explicit reason should take precedence over the resolve level fallback.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": False, + "can_upload_reason": "not_activated", + "can_upload_resolve_level": "user", + } + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == { + "result": False, + "reason": "not_activated", + } + + def test_api_entitlements_deploycenter_missing_base_url_parameter(): """Missing base_url parameter should raise an exception.""" with pytest.raises(TypeError): From 780c63bb08a0e1adc2908ec8d9d9be1ee775d7af Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:34:05 +0200 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8(backend)=20invalidate=20per-use?= =?UTF-8?q?r=20storage=20caches=20on=20item=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quota gauge reads the storage usage from cache (directly for the local backend, via DeployCenter's metrics for the remote one). Both caches must be dropped as soon as a write changes a user's usage, otherwise the gauge shows stale numbers until the timeout. A post_save signal covers item saves, and the code paths doing bulk updates that bypass signals (signup invitations, user reconciliation, hard delete of a tree) invalidate explicitly for every impacted creator. --- .../core/entitlements/backends/base.py | 3 + .../entitlements/backends/deploycenter.py | 4 + src/backend/core/models.py | 21 +++ src/backend/core/signals.py | 18 +++ src/backend/core/storage/cache.py | 23 +++ src/backend/core/tests/storage/test_cache.py | 21 +++ .../test_api_entitlements_deploycenter.py | 131 +++++++++++++++++- 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/backend/core/storage/cache.py create mode 100644 src/backend/core/tests/storage/test_cache.py diff --git a/src/backend/core/entitlements/backends/base.py b/src/backend/core/entitlements/backends/base.py index 914e9224e..ae187a704 100644 --- a/src/backend/core/entitlements/backends/base.py +++ b/src/backend/core/entitlements/backends/base.py @@ -57,3 +57,6 @@ def get_context(self, user): # pylint: disable=unused-argument def get_quota(self, user): # pylint: disable=unused-argument """Get quota for a user.""" return {} + + def invalidate_cache(self, user_ids): # noqa: B027 + """Invalidate any cached entitlements for these users. No-op by default.""" diff --git a/src/backend/core/entitlements/backends/deploycenter.py b/src/backend/core/entitlements/backends/deploycenter.py index 899ea1d7c..4908672b8 100644 --- a/src/backend/core/entitlements/backends/deploycenter.py +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -65,6 +65,10 @@ def get_entitlements(self, user): cache.set(cache_key, entitlements, timeout=self.cache_timeout) return entitlements + def invalidate_cache(self, user_ids): + """Drop cached entitlements so the next read refetches from DeployCenter.""" + cache.delete_many([f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{user_id}" for user_id in user_ids]) + def get_context(self, user): """Get context for a user.""" attributes_whitelist = ["organization", "operator", "potentialOperators"] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ad065b2c9..2c8df87bc 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -42,6 +42,7 @@ from pydantic import BaseModel as PydanticBaseModel from timezone_field import TimeZoneField +from core.storage.cache import invalidate_storage_used_cache from core.utils.item_title import manage_unique_title as manage_unique_title_utils from wopi.conversion.policy import target_extension_for @@ -317,6 +318,9 @@ def _convert_valid_invitations(self): # Set creator of items if not yet set (e.g. items created via server-to-server API) item_ids = [invitation.item_id for invitation in valid_invitations] Item.objects.filter(id__in=item_ids, creator__isnull=True).update(creator=self) + # The bulk update bypasses the post_save signal invalidating the + # storage used cache. + transaction.on_commit(lambda: invalidate_storage_used_cache([self.id])) valid_invitations.delete() @@ -497,6 +501,11 @@ def process_reconciliation_request(self): LinkTrace.objects.filter(id__in=ids_to_delete).delete() Item.objects.bulk_update(updated_items, ["creator"]) + # The bulk update bypasses the post_save signal invalidating the + # storage used cache, and both users' usage change. + transaction.on_commit( + lambda: invalidate_storage_used_cache([self.active_user_id, self.inactive_user_id]) + ) Invitation.objects.bulk_update(updated_invitations, ["issuer"]) User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"]) @@ -1473,12 +1482,24 @@ def hard_delete(self): } ) + # Collect the creators impacted before marking the tree as hard deleted: + # descendants can have different creators and their bulk update below + # bypasses the post_save signal invalidating the storage used cache. + creator_ids = set( + self.descendants() + .filter(hard_deleted_at__isnull=True) + .values_list("creator_id", flat=True) + ) + creator_ids.add(self.creator_id) + self.hard_deleted_at = timezone.now() self.save(update_fields=["hard_deleted_at"]) # Mark all descendants as hard deleted self.descendants().update(hard_deleted_at=self.hard_deleted_at) + transaction.on_commit(lambda: invalidate_storage_used_cache(creator_ids)) + @transaction.atomic def restore(self): """Cancelling a soft delete with checks.""" diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index a88dc2ee3..c7edc3f23 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -9,8 +9,12 @@ from django.dispatch import receiver from . import models +from .storage.cache import invalidate_storage_used_cache from .tasks.search import trigger_batch_file_indexer +# Item fields whose update can change the storage used by a user. +STORAGE_USED_FIELDS = {"size", "creator", "creator_id", "hard_deleted_at"} + @receiver(signals.post_save, sender=models.Item) def file_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument @@ -22,6 +26,20 @@ def file_post_save(sender, instance, **kwargs): # pylint: disable=unused-argume transaction.on_commit(partial(trigger_batch_file_indexer, instance)) +@receiver(signals.post_save, sender=models.Item) +def item_post_save_invalidate_storage_used(sender, instance, update_fields, **kwargs): # pylint: disable=unused-argument + """ + Invalidate the creator's cached storage usage when an item save may have + changed it. Bulk queryset updates bypass this signal and must invalidate + the cache explicitly. + """ + if update_fields is not None and not STORAGE_USED_FIELDS.intersection(update_fields): + return + if instance.creator_id is None: + return + transaction.on_commit(partial(invalidate_storage_used_cache, [instance.creator_id])) + + @receiver(signals.post_save, sender=models.ItemAccess) def file_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument """ diff --git a/src/backend/core/storage/cache.py b/src/backend/core/storage/cache.py new file mode 100644 index 000000000..724c84bcb --- /dev/null +++ b/src/backend/core/storage/cache.py @@ -0,0 +1,23 @@ +"""Cache helpers for per-user storage usage values.""" + +from django.core.cache import cache + +STORAGE_USED_CACHE_KEY_PREFIX = "storage_used:user:" + + +def get_storage_used_cache_key(user_id): + """Build the cache key holding the storage used by a user.""" + return f"{STORAGE_USED_CACHE_KEY_PREFIX}{user_id}" + + +def invalidate_storage_used_cache(user_ids): + """Invalidate the per-user usage caches (storage used + entitlements).""" + user_ids = [user_id for user_id in user_ids if user_id] + if not user_ids: + return + cache.delete_many([get_storage_used_cache_key(user_id) for user_id in user_ids]) + # Imported lazily: the local entitlements backend imports this module. + # pylint: disable-next=import-outside-toplevel + from core.entitlements import get_entitlements_backend # noqa: PLC0415 + + get_entitlements_backend().invalidate_cache(user_ids) diff --git a/src/backend/core/tests/storage/test_cache.py b/src/backend/core/tests/storage/test_cache.py new file mode 100644 index 000000000..da91213ca --- /dev/null +++ b/src/backend/core/tests/storage/test_cache.py @@ -0,0 +1,21 @@ +"""Tests for the per-user usage cache helpers.""" + +from unittest import mock + +from core.storage.cache import invalidate_storage_used_cache + + +def test_invalidate_storage_used_cache_invalidates_entitlements(): + """The entitlements backend cache should be invalidated for the filtered user ids.""" + with mock.patch("core.entitlements.get_entitlements_backend") as mock_get_backend: + invalidate_storage_used_cache(["user-1", None, "user-2"]) + + mock_get_backend.return_value.invalidate_cache.assert_called_once_with(["user-1", "user-2"]) + + +def test_invalidate_storage_used_cache_without_user_ids(): + """Nothing should be invalidated when no valid user id is given.""" + with mock.patch("core.entitlements.get_entitlements_backend") as mock_get_backend: + invalidate_storage_used_cache([None]) + + mock_get_backend.assert_not_called() diff --git a/src/backend/core/tests/test_api_entitlements_deploycenter.py b/src/backend/core/tests/test_api_entitlements_deploycenter.py index dc4556ca9..a5ce99d5c 100644 --- a/src/backend/core/tests/test_api_entitlements_deploycenter.py +++ b/src/backend/core/tests/test_api_entitlements_deploycenter.py @@ -3,15 +3,24 @@ """ import urllib.parse +from io import BytesIO +from unittest import mock +from django.core.cache import cache +from django.core.files.storage import default_storage from django.test import override_settings import pytest import responses from rest_framework.test import APIClient -from core import factories -from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend +from core import factories, models +from core.api.viewsets import malware_detection +from core.entitlements import get_entitlements_backend +from core.entitlements.backends.deploycenter import ( + ENTITLEMENTS_CACHE_KEY_PREFIX, + DeployCenterEntitlementsBackend, +) pytestmark = pytest.mark.django_db @@ -300,6 +309,124 @@ def test_api_entitlements_deploycenter_get_entitlements_cache(): assert len(responses.calls) == 1 +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_invalidate_cache(): + """Invalidating the cache should force the next read to hit DeployCenter again.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": True, + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_authenticate(user) + + client.get("/api/v1.0/entitlements/") + client.get("/api/v1.0/entitlements/") + assert len(responses.calls) == 1 + + get_entitlements_backend().invalidate_cache([user.id]) + assert cache.get(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{user.id}") is None + + client.get("/api/v1.0/entitlements/") + assert len(responses.calls) == 2 + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_cache_invalidated_on_upload_ended( + django_capture_on_commit_callbacks, +): + """Ending an upload should invalidate the uploader's cached entitlements.""" + responses.add( + responses.GET, + ENTITLEMENTS_URL, + json={ + "entitlements": { + "can_access": True, + "can_upload": True, + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, + }, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={"siret": "12345678901234"}) + client.force_login(user) + + item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, filename="my_file.txt", creator=user + ) + factories.UserItemAccessFactory(item=item, user=user, role="owner") + default_storage.save(item.file_key, BytesIO(b"my prose")) + + with ( + mock.patch.object(malware_detection, "analyse_file"), + django_capture_on_commit_callbacks(execute=True), + ): + response = client.post(f"/api/v1.0/items/{item.id!s}/upload-ended/") + assert response.status_code == 200 + + # The can_upload gate cached pre-upload entitlements during the request: + # they must be gone once the upload has been committed. + assert len(responses.calls) == 1 + assert cache.get(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{user.id}") is None + + client.get("/api/v1.0/entitlements/") + assert len(responses.calls) == 2 + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +def test_api_entitlements_deploycenter_cache_invalidated_on_hard_delete( + django_capture_on_commit_callbacks, +): + """Hard deleting a folder should invalidate every descendant creator's entitlements.""" + owner = factories.UserFactory() + other = factories.UserFactory() + folder = factories.ItemFactory(creator=owner, type=models.ItemTypeChoices.FOLDER) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, parent=folder, creator=owner, size=100) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, parent=folder, creator=other, size=200) + + cache.set(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{owner.id}", {"entitlements": {}}) + cache.set(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{other.id}", {"entitlements": {}}) + + folder.soft_delete() + with django_capture_on_commit_callbacks(execute=True): + folder.hard_delete() + + assert cache.get(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{owner.id}") is None + assert cache.get(f"{ENTITLEMENTS_CACHE_KEY_PREFIX}{other.id}") is None + + @override_settings( ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, From 6c61275669010f97e13e1677260379cf8cd5b34e Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:34:27 +0200 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=90=9B(backend)=20exclude=20hard-de?= =?UTF-8?q?leted=20items=20from=20storage=20computation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-deleted items no longer occupy storage but still counted in the creator's usage, inflating the quota gauge and blocking uploads for users who had cleaned up their trash. The new partial covering index keeps the per-creator sum an index-only scan now that the quota check runs it on every uncached upload. --- ...026_item_item_creator_size_not_hdel_idx.py | 22 ++++++++++ src/backend/core/models.py | 7 ++++ .../creator_storage_compute_backend.py | 6 +-- .../test_creator_storage_compute_backend.py | 41 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/backend/core/migrations/0026_item_item_creator_size_not_hdel_idx.py create mode 100644 src/backend/core/tests/storage/test_creator_storage_compute_backend.py diff --git a/src/backend/core/migrations/0026_item_item_creator_size_not_hdel_idx.py b/src/backend/core/migrations/0026_item_item_creator_size_not_hdel_idx.py new file mode 100644 index 000000000..da815757a --- /dev/null +++ b/src/backend/core/migrations/0026_item_item_creator_size_not_hdel_idx.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.14 on 2026-07-08 10:14 + +from django.contrib.postgres.operations import AddIndexConcurrently +from django.db import migrations, models + + +class Migration(migrations.Migration): + + # CREATE INDEX CONCURRENTLY cannot run inside a transaction. It avoids + # locking writes on the item table while the index is being built. + atomic = False + + dependencies = [ + ('core', '0025_user_storage_limit_override'), + ] + + operations = [ + AddIndexConcurrently( + model_name='item', + index=models.Index(condition=models.Q(('hard_deleted_at__isnull', True)), fields=['creator'], include=('size',), name='item_creator_size_not_hdel_idx'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2c8df87bc..d91bfce4e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1034,6 +1034,13 @@ class Meta: indexes = [ GistIndex(fields=["path"]), models.Index(NLevel(models.F("path")), name="drive_item_path_nlevel_idx"), + # Covers the storage used computation by creator. + models.Index( + fields=["creator"], + include=["size"], + condition=models.Q(hard_deleted_at__isnull=True), + name="item_creator_size_not_hdel_idx", + ), ] def __str__(self): diff --git a/src/backend/core/storage/creator_storage_compute_backend.py b/src/backend/core/storage/creator_storage_compute_backend.py index a21864c28..c2fb7082f 100644 --- a/src/backend/core/storage/creator_storage_compute_backend.py +++ b/src/backend/core/storage/creator_storage_compute_backend.py @@ -15,6 +15,6 @@ def compute_storage_used(self, users): """ Compute the total storage used by a set of users. """ - return Item.objects.filter(creator__in=users).aggregate(total_size=Sum("size", default=0))[ - "total_size" - ] + return Item.objects.filter(creator__in=users, hard_deleted_at__isnull=True).aggregate( + total_size=Sum("size", default=0) + )["total_size"] diff --git a/src/backend/core/tests/storage/test_creator_storage_compute_backend.py b/src/backend/core/tests/storage/test_creator_storage_compute_backend.py new file mode 100644 index 000000000..21d9b6f03 --- /dev/null +++ b/src/backend/core/tests/storage/test_creator_storage_compute_backend.py @@ -0,0 +1,41 @@ +""" +Tests for the CreatorStorageComputeBackend. +""" + +import pytest + +from core import factories +from core.storage.creator_storage_compute_backend import CreatorStorageComputeBackend + +pytestmark = pytest.mark.django_db + + +def test_compute_storage_used_sums_creator_items(): + """The backend should sum the sizes of all items created by the given users.""" + user = factories.UserFactory() + factories.ItemFactory(creator=user, size=100) + factories.ItemFactory(creator=user, size=250) + factories.ItemFactory(size=999) # another creator, should not count + + assert CreatorStorageComputeBackend().compute_storage_used([user]) == 350 + + +def test_compute_storage_used_excludes_hard_deleted_items(): + """Hard-deleted items should not count toward the storage used.""" + user = factories.UserFactory() + factories.ItemFactory(creator=user, size=100) + hard_deleted = factories.ItemFactory(creator=user, size=250) + hard_deleted.soft_delete() + hard_deleted.hard_delete() + + assert CreatorStorageComputeBackend().compute_storage_used([user]) == 100 + + +def test_compute_storage_used_keeps_soft_deleted_items(): + """Soft-deleted (trashbin) items should still count toward the storage used.""" + user = factories.UserFactory() + factories.ItemFactory(creator=user, size=100) + soft_deleted = factories.ItemFactory(creator=user, size=250) + soft_deleted.soft_delete() + + assert CreatorStorageComputeBackend().compute_storage_used([user]) == 350 From 0f7ee3a3ed9ff25c5956b35ef7fe76ca6bb19073 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:34:45 +0200 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20local=20en?= =?UTF-8?q?titlements=20backend=20with=20storage=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployments without a DeployCenter service had no way to enforce storage quotas. This backend computes each user's usage locally and applies a configurable default limit, overridable per user in the admin (0 meaning unlimited) and skippable for users created before a cutoff so quotas can be rolled out to new users only. The quota is soft: can_upload runs before the file size is known, so one upload can overshoot before the next one is blocked. --- CHANGELOG.md | 1 + docs/entitlements.md | 28 + src/backend/core/admin.py | 2 + .../core/entitlements/backends/local.py | 138 +++++ .../0025_user_storage_limit_override.py | 19 + src/backend/core/models.py | 13 + .../core/tests/test_api_entitlements_local.py | 494 ++++++++++++++++++ 7 files changed, 695 insertions(+) create mode 100644 src/backend/core/entitlements/backends/local.py create mode 100644 src/backend/core/migrations/0025_user_storage_limit_override.py create mode 100644 src/backend/core/tests/test_api_entitlements_local.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 624c18f09..e6d85b93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to ### Added +- ✨(backend) add a local entitlements backend with per-user storage limits - ✨(backend) allow converting a file while it is being analyzed - ✨(frontend) add file type, contact and modification date topbar filters - ✨(frontend) add location, file type, contact and date search filters diff --git a/docs/entitlements.md b/docs/entitlements.md index 1127e99a6..25f6ff216 100644 --- a/docs/entitlements.md +++ b/docs/entitlements.md @@ -44,6 +44,34 @@ ENTITLEMENTS_BACKEND_PARAMETERS = { } ``` +### Local Backend + +The `LocalEntitlementsBackend` enforces a per-user storage quota computed from local data, without relying on any external service. Every user gets a default limit (10 GiB unless configured otherwise), and the usage is computed by the configured storage compute backend (by default, the sum of the sizes of the items the user created — files count against their creator). + +**Configuration:** + +```python +ENTITLEMENTS_BACKEND = "core.entitlements.backends.local.LocalEntitlementsBackend" +ENTITLEMENTS_BACKEND_PARAMETERS = { + # Default storage limit in bytes (optional, defaults to 10 GiB). + "default_storage_limit": 10737418240, + # Users created before this datetime have no limit (optional). + "exempt_users_created_before": "2026-01-01T00:00:00+00:00", + # Safety net expiry in seconds for the cached usage (optional, defaults to 3600). + "cache_timeout": 3600, +} +``` + +**Caching:** the storage used by each user is cached (`storage_used:user:` key) and invalidated whenever an item write changes it (upload, collaborative save, conversion, duplication, hard delete, creator reassignment). The `cache_timeout` expiry is only a safety net: a value primed concurrently with a write can stay stale for up to that duration. + +**Per-user override:** the limit can be overridden for each user through the `storage_limit_override` field, editable in the Django admin. Leave it empty to apply the configured default limit, set it to `0` for unlimited storage, or set any positive number of bytes. The override always takes precedence over the `exempt_users_created_before` cutoff. + +**Grandfathering:** when `exempt_users_created_before` is set, users created before that datetime (and without an override) have no storage limit. This allows rolling out quotas for new users only. + +Users without a limit (grandfathered or override set to `0`) get no `quota` entry in the entitlements response, so no quota gauge is rendered. + +Note that the quota is soft: `can_upload` is checked before the file size is known, so a single upload can overshoot the limit; the next one is then blocked. + ### DeployCenter Backend The `DeployCenterEntitlementsBackend` integrates with an external [DeployCenter](https://github.com/suitenumerique/st-deploycenter) entitlements service to check user permissions based on their account email and other OIDC claims. diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 4da2b4b74..5cfb4720e 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -61,6 +61,7 @@ class UserAdmin(auth_admin.UserAdmin): ), }, ), + (_("Entitlements"), {"fields": ("storage_limit_override",)}), (_("Important dates"), {"fields": ("created_at", "updated_at")}), ) add_fieldsets = ( @@ -82,6 +83,7 @@ class UserAdmin(auth_admin.UserAdmin): "is_staff", "is_superuser", "is_device", + "storage_limit_override", "created_at", "updated_at", ) diff --git a/src/backend/core/entitlements/backends/local.py b/src/backend/core/entitlements/backends/local.py new file mode 100644 index 000000000..9f6ddc491 --- /dev/null +++ b/src/backend/core/entitlements/backends/local.py @@ -0,0 +1,138 @@ +"""Local Entitlements Backend.""" + +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from core.entitlements.backends.base import ( + CanUploadReason, + EntitlementsBackend, + QuotaState, +) +from core.storage import get_storage_compute_backend +from core.storage.cache import get_storage_used_cache_key + +DEFAULT_STORAGE_LIMIT = 10 * 1024**3 # 10 GiB +DEFAULT_CACHE_TIMEOUT = 3600 + + +class LocalEntitlementsBackend(EntitlementsBackend): + """ + Entitlements backend enforcing per-user storage limits from local data. + + Every user gets a default storage limit, overridable per user via the + ``storage_limit_override`` field on the User model (null: use the default + limit, 0: unlimited). Users created before ``exempt_users_created_before`` + and without an override are exempted from any limit. + + The quota is soft: ``can_upload`` is checked before the file size is + known, so a single upload can overshoot the limit; the next one is then + blocked. + """ + + def __init__( + self, + default_storage_limit=DEFAULT_STORAGE_LIMIT, + exempt_users_created_before=None, + cache_timeout=DEFAULT_CACHE_TIMEOUT, + ): + self.default_storage_limit = int(default_storage_limit) + if self.default_storage_limit <= 0: + raise ImproperlyConfigured( + "LocalEntitlementsBackend: 'default_storage_limit' must be a " + "positive number of bytes." + ) + + self.cache_timeout = int(cache_timeout) + if self.cache_timeout <= 0: + raise ImproperlyConfigured( + "LocalEntitlementsBackend: 'cache_timeout' must be a positive number of seconds." + ) + + self.exempt_users_created_before = None + if exempt_users_created_before is not None: + parsed = parse_datetime(str(exempt_users_created_before)) + if parsed is None: + raise ImproperlyConfigured( + "LocalEntitlementsBackend: 'exempt_users_created_before' must " + "be an ISO 8601 datetime string." + ) + if timezone.is_naive(parsed): + parsed = timezone.make_aware(parsed) + self.exempt_users_created_before = parsed + + def get_storage_limit(self, user): + """ + Return the effective storage limit in bytes for a user, or None if the + user has no limit. The per-user override always takes precedence over + the exemption cutoff. + """ + override = getattr(user, "storage_limit_override", None) + if override is not None: + return None if override == 0 else override + + if ( + self.exempt_users_created_before is not None + and user.created_at < self.exempt_users_created_before + ): + return None + + return self.default_storage_limit + + def get_storage_used(self, user): + """ + Get the storage currently used by a user, cached. + + The cached value is invalidated whenever an item write changes the + user's usage; the timeout is only a safety net against stale values. + """ + cache_key = get_storage_used_cache_key(user.id) + usage = cache.get(cache_key) + if usage is None: + usage = get_storage_compute_backend().compute_storage_used([user]) + cache.set(cache_key, usage, timeout=self.cache_timeout) + return usage + + def can_access(self, user): + """Check if a user can access the app.""" + return {"result": True} + + def can_upload(self, user): + """Check if a user can upload a file.""" + if not user.is_authenticated: + return {"result": False} + + limit = self.get_storage_limit(user) + if limit is None: + return {"result": True} + + if self.get_storage_used(user) >= limit: + override_set = getattr(user, "storage_limit_override", None) is not None + return { + "result": False, + "reason": ( + CanUploadReason.USER_OVERRIDE_QUOTA_EXCEDEED + if override_set + else CanUploadReason.USER_QUOTA_EXCEDEED + ), + "message": "You have exceeded your storage limit.", + } + + return {"result": True} + + def get_quota(self, user): + """Get quota for a user.""" + if not user.is_authenticated: + return {} + + limit = self.get_storage_limit(user) + # Unlimited users (override set to 0 or exempted): do not render the gauge. + if limit is None: + return {} + + return { + "state": QuotaState.DEFAULT, + "usage": self.get_storage_used(user), + "limit": limit, + } diff --git a/src/backend/core/migrations/0025_user_storage_limit_override.py b/src/backend/core/migrations/0025_user_storage_limit_override.py new file mode 100644 index 000000000..a155f5794 --- /dev/null +++ b/src/backend/core/migrations/0025_user_storage_limit_override.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.14 on 2026-07-08 09:38 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0024_alter_item_upload_state'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='storage_limit_override', + field=models.BigIntegerField(blank=True, default=None, help_text='Storage limit in bytes for this user, used by the local entitlements backend. Leave empty to use the configured default limit. Set to 0 for unlimited storage.', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='storage limit override'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d91bfce4e..74957ee22 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -256,6 +256,19 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): ), ) + storage_limit_override = models.BigIntegerField( + _("storage limit override"), + help_text=_( + "Storage limit in bytes for this user, used by the local entitlements " + "backend. Leave empty to use the configured default limit. " + "Set to 0 for unlimited storage." + ), + null=True, + blank=True, + default=None, + validators=[validators.MinValueValidator(0)], + ) + claims = models.JSONField( blank=True, default=dict, diff --git a/src/backend/core/tests/test_api_entitlements_local.py b/src/backend/core/tests/test_api_entitlements_local.py new file mode 100644 index 000000000..c02f1a7bf --- /dev/null +++ b/src/backend/core/tests/test_api_entitlements_local.py @@ -0,0 +1,494 @@ +""" +Test Entitlements API endpoints with Local entitlements backend. +""" + +from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured +from django.test import override_settings +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.entitlements.backends.local import ( + DEFAULT_STORAGE_LIMIT, + LocalEntitlementsBackend, +) +from core.storage.cache import get_storage_used_cache_key + +pytestmark = pytest.mark.django_db + +LOCAL_BACKEND = "core.entitlements.backends.local.LocalEntitlementsBackend" + + +def backdate_user(user, created_at): + """Backdate a user's creation date (created_at is auto_now_add).""" + models.User.objects.filter(pk=user.pk).update(created_at=created_at) + user.refresh_from_db() + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={}, +) +def test_api_entitlements_local_anonymous(): + """Anonymous users should not be allowed to get entitlements.""" + client = APIClient() + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 401 + assert response.json() == { + "errors": [ + { + "attr": None, + "code": "not_authenticated", + "detail": "Authentication credentials were not provided.", + }, + ], + "type": "client_error", + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={}, +) +def test_api_entitlements_local_defaults_no_usage(): + """Without parameters, users get the default 10 GiB limit and no usage.""" + client = APIClient() + user = factories.UserFactory() + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json() == { + "can_access": {"result": True}, + "can_upload": {"result": True}, + "quota": { + "state": "default", + "usage": 0, + "limit": DEFAULT_STORAGE_LIMIT, + }, + "context": {}, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_under_limit(): + """Users under the default limit can upload and see their usage.""" + client = APIClient() + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=400) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == {"result": True} + assert response.json()["quota"] == { + "state": "default", + "usage": 400, + "limit": 1000, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_over_default_limit(): + """Users over the default limit cannot upload but still see the gauge.""" + client = APIClient() + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1500) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == { + "result": False, + "reason": "user_quota_excedeed", + "message": "You have exceeded your storage limit.", + } + assert response.json()["quota"] == { + "state": "default", + "usage": 1500, + "limit": 1000, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_at_exact_limit(): + """Users exactly at the limit cannot start a new upload.""" + client = APIClient() + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1000) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"]["result"] is False + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_override_raises_limit(): + """A per-user override higher than the default limit takes precedence.""" + client = APIClient() + user = factories.UserFactory(storage_limit_override=5000) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1500) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == {"result": True} + assert response.json()["quota"] == { + "state": "default", + "usage": 1500, + "limit": 5000, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_override_exceeded(): + """Users exceeding their own override get a dedicated reason.""" + client = APIClient() + user = factories.UserFactory(storage_limit_override=500) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=600) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == { + "result": False, + "reason": "user_override_quota_excedeed", + "message": "You have exceeded your storage limit.", + } + assert response.json()["quota"] == { + "state": "default", + "usage": 600, + "limit": 500, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_override_zero_unlimited(): + """An override set to 0 means unlimited storage and no quota gauge.""" + client = APIClient() + user = factories.UserFactory(storage_limit_override=0) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=10**12) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == {"result": True} + assert "quota" not in response.json() + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={ + "default_storage_limit": 1000, + "exempt_users_created_before": "2026-01-01T00:00:00+00:00", + }, +) +def test_api_entitlements_local_grandfathered_user(): + """Users created before the cutoff have no limit and no quota gauge.""" + client = APIClient() + user = factories.UserFactory() + backdate_user(user, "2025-12-31T23:59:59+00:00") + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=10**12) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == {"result": True} + assert "quota" not in response.json() + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={ + "default_storage_limit": 1000, + "exempt_users_created_before": "2026-01-01T00:00:00+00:00", + }, +) +def test_api_entitlements_local_user_created_after_cutoff(): + """Users created after the cutoff are limited normally.""" + client = APIClient() + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1500) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"]["result"] is False + assert response.json()["quota"] == { + "state": "default", + "usage": 1500, + "limit": 1000, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={ + "default_storage_limit": 1000, + "exempt_users_created_before": "2026-01-01T00:00:00+00:00", + }, +) +def test_api_entitlements_local_override_beats_grandfathering(): + """A per-user override applies even to users created before the cutoff.""" + client = APIClient() + user = factories.UserFactory(storage_limit_override=500) + backdate_user(user, "2025-12-31T23:59:59+00:00") + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=600) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == { + "result": False, + "reason": "user_override_quota_excedeed", + "message": "You have exceeded your storage limit.", + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_usage_excludes_hard_deleted(): + """Hard deleted items should not count against the quota.""" + client = APIClient() + user = factories.UserFactory() + item = factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1500) + models.Item.objects.filter(pk=item.pk).update(hard_deleted_at=timezone.now()) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert response.json()["can_upload"] == {"result": True} + assert response.json()["quota"] == { + "state": "default", + "usage": 0, + "limit": 1000, + } + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_upload_blocked_via_items_api(): + """Creating a file item over quota should be denied with the backend message.""" + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=1500) + + client = APIClient() + client.force_login(user) + + access = factories.UserItemAccessFactory( + user=user, role="editor", item__type=models.ItemTypeChoices.FOLDER + ) + response = client.post( + f"/api/v1.0/items/{access.item.id!s}/children/", + { + "type": models.ItemTypeChoices.FILE, + "filename": "file.txt", + }, + ) + assert response.status_code == 403 + assert response.json() == { + "type": "client_error", + "errors": [ + { + # The can_upload reason is exposed as the error code so the + # frontend can show a specific, translatable message. + "code": "user_quota_excedeed", + "detail": "You have exceeded your storage limit.", + "attr": None, + } + ], + } + + +def test_api_entitlements_local_invalid_cutoff_parameter(): + """An unparseable cutoff datetime should raise ImproperlyConfigured.""" + with pytest.raises(ImproperlyConfigured): + LocalEntitlementsBackend(exempt_users_created_before="not-a-date") + + +def test_api_entitlements_local_naive_cutoff_parameter_made_aware(): + """A naive ISO cutoff should be accepted and made timezone-aware.""" + backend = LocalEntitlementsBackend(exempt_users_created_before="2026-01-01T00:00:00") + assert timezone.is_aware(backend.exempt_users_created_before) + + +def test_api_entitlements_local_invalid_default_storage_limit(): + """A non-positive default storage limit should raise ImproperlyConfigured.""" + with pytest.raises(ImproperlyConfigured): + LocalEntitlementsBackend(default_storage_limit=0) + + +def test_api_entitlements_local_string_default_storage_limit(): + """A default storage limit passed as a string should be coerced to int.""" + backend = LocalEntitlementsBackend(default_storage_limit="1000") + assert backend.default_storage_limit == 1000 + + +def test_api_entitlements_local_invalid_cache_timeout(): + """A non-positive cache timeout should raise ImproperlyConfigured.""" + with pytest.raises(ImproperlyConfigured): + LocalEntitlementsBackend(cache_timeout=0) + + +def test_api_entitlements_local_string_cache_timeout(): + """A cache timeout passed as a string should be coerced to int.""" + backend = LocalEntitlementsBackend(cache_timeout="60") + assert backend.cache_timeout == 60 + + +def test_api_entitlements_local_can_upload_anonymous(): + """Anonymous users cannot upload, even with an exemption cutoff configured.""" + backend = LocalEntitlementsBackend(exempt_users_created_before="2026-01-01T00:00:00+00:00") + assert backend.can_upload(AnonymousUser()) == {"result": False} + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_usage_is_cached(): + """The storage used should be cached and reused on subsequent calls.""" + client = APIClient() + user = factories.UserFactory() + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=400) + client.force_authenticate(user) + + response = client.get("/api/v1.0/entitlements/") + assert response.json()["quota"]["usage"] == 400 + assert cache.get(get_storage_used_cache_key(user.id)) == 400 + + # Poison the cache to prove the next call reads from it instead of the database. + cache.set(get_storage_used_cache_key(user.id), 999) + response = client.get("/api/v1.0/entitlements/") + assert response.json()["quota"]["usage"] == 999 + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_zero_usage_is_cached(): + """A zero usage should be cached, not treated as a cache miss.""" + client = APIClient() + user = factories.UserFactory() + client.force_authenticate(user) + + response = client.get("/api/v1.0/entitlements/") + assert response.json()["quota"]["usage"] == 0 + assert cache.get(get_storage_used_cache_key(user.id), "MISS") == 0 + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_entitlements_local_cache_invalidated_on_item_save( + django_capture_on_commit_callbacks, +): + """Saving an item size should invalidate the creator's cached usage.""" + client = APIClient() + user = factories.UserFactory() + item = factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=400) + client.force_authenticate(user) + + response = client.get("/api/v1.0/entitlements/") + assert response.json()["quota"]["usage"] == 400 + + item.size = 700 + with django_capture_on_commit_callbacks(execute=True): + item.save(update_fields=["size"]) + + assert cache.get(get_storage_used_cache_key(user.id)) is None + response = client.get("/api/v1.0/entitlements/") + assert response.json()["quota"]["usage"] == 700 + + +def test_api_entitlements_local_cache_invalidated_on_hard_delete( + django_capture_on_commit_callbacks, +): + """Hard deleting a folder should invalidate the cache of every descendant creator.""" + owner = factories.UserFactory() + other = factories.UserFactory() + folder = factories.ItemFactory(creator=owner, type=models.ItemTypeChoices.FOLDER) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, parent=folder, creator=owner, size=100) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, parent=folder, creator=other, size=200) + + cache.set(get_storage_used_cache_key(owner.id), 100) + cache.set(get_storage_used_cache_key(other.id), 200) + + folder.soft_delete() + with django_capture_on_commit_callbacks(execute=True): + folder.hard_delete() + + assert cache.get(get_storage_used_cache_key(owner.id)) is None + assert cache.get(get_storage_used_cache_key(other.id)) is None + + +def test_api_entitlements_local_cache_invalidated_on_move_to_root( + django_capture_on_commit_callbacks, +): + """Moving an item to the root should invalidate the previous creator's cache.""" + creator = factories.UserFactory() + mover = factories.UserFactory() + client = APIClient() + client.force_login(mover) + + folder = factories.ItemFactory( + creator=creator, + users=[(creator, models.RoleChoices.OWNER), (mover, models.RoleChoices.OWNER)], + type=models.ItemTypeChoices.FOLDER, + ) + item = factories.ItemFactory(creator=creator, parent=folder, type=models.ItemTypeChoices.FOLDER) + + cache.set(get_storage_used_cache_key(creator.id), 123) + cache.set(get_storage_used_cache_key(mover.id), 456) + + with django_capture_on_commit_callbacks(execute=True): + response = client.post(f"/api/v1.0/items/{item.id!s}/move/", data={}) + assert response.status_code == 200 + + item.refresh_from_db() + assert item.creator == mover + assert cache.get(get_storage_used_cache_key(creator.id)) is None + assert cache.get(get_storage_used_cache_key(mover.id)) is None + + +def test_api_entitlements_local_cache_invalidated_on_reconciliation( + django_capture_on_commit_callbacks, +): + """Reconciling two accounts should invalidate both users' cached usage.""" + active = factories.UserFactory(email="active@example.com") + inactive = factories.UserFactory(email="inactive@example.com") + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=inactive, size=300) + + reconciliation = models.UserReconciliation.objects.create( + active_email=active.email, + inactive_email=inactive.email, + active_email_checked=True, + inactive_email_checked=True, + ) + + cache.set(get_storage_used_cache_key(active.id), 0) + cache.set(get_storage_used_cache_key(inactive.id), 300) + + with django_capture_on_commit_callbacks(execute=True): + reconciliation.process_reconciliation_request() + + assert cache.get(get_storage_used_cache_key(active.id)) is None + assert cache.get(get_storage_used_cache_key(inactive.id)) is None From a84851effff0b84282bbf92154673e2ecfe69845 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:35:05 +0200 Subject: [PATCH 06/19] =?UTF-8?q?=E2=9C=A8(backend)=20gate=20move-to-root?= =?UTF-8?q?=20and=20duplicate=20on=20the=20upload=20entitlement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both actions grow the acting user's storage usage without going through an upload: duplicating makes them creator of a new sized copy, and moving a file to the root without a direct access reassigns its creator. An over-quota user could use them to keep taking ownership of storage. Moving also invalidates the previous creator's usage cache since the reassignment only triggers the post_save invalidation for the new creator. --- src/backend/core/api/viewsets.py | 28 +++- .../tests/items/test_api_items_duplicate.py | 38 +++++ .../core/tests/items/test_api_items_move.py | 149 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 84a483bb5..8f79d4c80 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -54,6 +54,7 @@ get_visited_items_ids_of, ) from core.storage import get_storage_compute_backend +from core.storage.cache import invalidate_storage_used_cache from core.tasks.item import duplicate_file, process_item_purge, rename_file from core.utils.analytics import posthog_capture from wopi.conversion import exceptions as conversion_exceptions @@ -1015,17 +1016,33 @@ def move(self, request, *args, **kwargs): {"target_item_id": message}, code="item_move_missing_permission" ) + # Moving a file to the root without a direct access reassigns its creator + # (see below), shifting the file size to the mover's storage usage: gate it + # like an upload so an over-quota user cannot take ownership of more storage. + has_direct_access = models.ItemAccess.objects.filter(item=item, user=user).exists() + if not target_item and not has_direct_access and item.type == models.ItemTypeChoices.FILE: + can_upload = get_entitlements_backend().can_upload(user) + if not can_upload["result"]: + raise drf.exceptions.PermissionDenied( + detail=can_upload.get("message", "You cannot take ownership of more storage."), + code=can_upload.get("reason"), + ) + item.move(target_item) # If the item is moved to the root and the user does not have an access on the item, # create an owner access for the user. Otherwise, the item will be invisible for the user. update_fields = [] - if not target_item and not models.ItemAccess.objects.filter(item=item, user=user).exists(): + if not target_item and not has_direct_access: models.ItemAccess.objects.create( item=item, user=self.request.user, role=models.RoleChoices.OWNER, ) + # The post_save signal only invalidates the storage used cache of + # the new creator, the previous one loses this item from its usage. + previous_creator_id = item.creator_id + transaction.on_commit(lambda: invalidate_storage_used_cache([previous_creator_id])) item.creator = user update_fields.append("creator") @@ -1709,6 +1726,15 @@ def duplicate(self, request, *args, **kwargs): item_to_duplicate = self.get_object() user = request.user + # The duplicator becomes the creator of a new sized file: gate it like an + # upload so an over-quota user cannot grow their storage usage. + can_upload = get_entitlements_backend().can_upload(user) + if not can_upload["result"]: + raise drf.exceptions.PermissionDenied( + detail=can_upload.get("message", "You do not have permission to upload files."), + code=can_upload.get("reason"), + ) + parent = item_to_duplicate.parent() if item_to_duplicate.depth > 1 else None if parent and parent.get_role(user) == models.RoleChoices.READER: diff --git a/src/backend/core/tests/items/test_api_items_duplicate.py b/src/backend/core/tests/items/test_api_items_duplicate.py index f7f0cd171..1e1c66e5e 100644 --- a/src/backend/core/tests/items/test_api_items_duplicate.py +++ b/src/backend/core/tests/items/test_api_items_duplicate.py @@ -4,6 +4,8 @@ from unittest import mock +from django.test import override_settings + import pytest from rest_framework.test import APIClient @@ -13,6 +15,42 @@ pytestmark = pytest.mark.django_db +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_items_duplicate_over_quota(): + """ + An over-quota user should not be able to grow their storage usage by + duplicating a file (the duplicator becomes creator of the sized copy). + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # The user already exceeds their storage limit with their own files. + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=2000) + + item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + mimetype="text/plain", + filename="myfile.txt", + size=100, + users=[(user, models.RoleChoices.EDITOR)], + ) + + response = client.post(f"/api/v1.0/items/{item.id!s}/duplicate/") + + assert response.status_code == 403 + # The can_upload reason is exposed as the error code so the frontend can + # show a specific, translatable message. + assert response.json()["errors"][0]["code"] == "user_quota_excedeed" + assert not models.Item.objects.filter( + upload_state=models.ItemUploadStateChoices.DUPLICATING + ).exists() + + def test_api_items_duplicate_anonymous_user(): """Anonymous users should not be able to duplicate items.""" item = factories.ItemFactory( diff --git a/src/backend/core/tests/items/test_api_items_move.py b/src/backend/core/tests/items/test_api_items_move.py index 9d22388ab..10353303a 100644 --- a/src/backend/core/tests/items/test_api_items_move.py +++ b/src/backend/core/tests/items/test_api_items_move.py @@ -6,6 +6,8 @@ from unittest import mock from uuid import uuid4 +from django.test import override_settings + import pytest from rest_framework.test import APIClient @@ -13,6 +15,8 @@ pytestmark = pytest.mark.django_db +LOCAL_BACKEND = "core.entitlements.backends.local.LocalEntitlementsBackend" + # pytest.skip("move API is not re implemented using ltree yet", allow_module_level=True) @@ -677,6 +681,151 @@ def test_api_items_move_to_root(): assert item.creator == mover +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_items_move_file_to_root_over_quota(): + """ + An over-quota user should not be able to take ownership of a file by moving + it from a shared folder to their own root. + """ + creator = factories.UserFactory() + mover = factories.UserFactory() + client = APIClient() + client.force_login(mover) + + # The mover already exceeds their storage limit with their own files. + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=mover, size=2000) + + folder = factories.ItemFactory( + creator=creator, + users=[(creator, models.RoleChoices.OWNER), (mover, models.RoleChoices.OWNER)], + type=models.ItemTypeChoices.FOLDER, + ) + item = factories.ItemFactory( + creator=creator, + parent=folder, + type=models.ItemTypeChoices.FILE, + size=100, + ) + + response = client.post(f"/api/v1.0/items/{item.id!s}/move/", data={}) + + assert response.status_code == 403 + # The can_upload reason is exposed as the error code so the frontend can + # show a specific, translatable message. + assert response.json()["errors"][0]["code"] == "user_quota_excedeed" + + # The item has not moved and its creator is unchanged. + item.refresh_from_db() + assert item.creator == creator + assert item.parent().id == folder.id + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_items_move_file_to_root_over_quota_with_direct_access(): + """ + An over-quota user moving a file they already have a direct access on should + not be blocked: the creator is not reassigned so no storage shifts. + """ + creator = factories.UserFactory() + mover = factories.UserFactory() + client = APIClient() + client.force_login(mover) + + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=mover, size=2000) + + folder = factories.ItemFactory( + creator=creator, + users=[(creator, models.RoleChoices.OWNER), (mover, models.RoleChoices.OWNER)], + type=models.ItemTypeChoices.FOLDER, + ) + item = factories.ItemFactory( + creator=creator, + parent=folder, + type=models.ItemTypeChoices.FILE, + size=100, + users=[(mover, models.RoleChoices.OWNER)], + ) + + response = client.post(f"/api/v1.0/items/{item.id!s}/move/", data={}) + + assert response.status_code == 200 + item.refresh_from_db() + assert item.parent() is None + assert item.creator == creator + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_items_move_folder_to_root_over_quota(): + """ + An over-quota user can still move a folder to their root: folders carry no + size and descendants keep their creators, so no storage shifts. + """ + creator = factories.UserFactory() + mover = factories.UserFactory() + client = APIClient() + client.force_login(mover) + + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=mover, size=2000) + + folder = factories.ItemFactory( + creator=creator, + users=[(creator, models.RoleChoices.OWNER), (mover, models.RoleChoices.OWNER)], + type=models.ItemTypeChoices.FOLDER, + ) + item = factories.ItemFactory( + creator=creator, + parent=folder, + type=models.ItemTypeChoices.FOLDER, + ) + + response = client.post(f"/api/v1.0/items/{item.id!s}/move/", data={}) + + assert response.status_code == 200 + item.refresh_from_db() + assert item.parent() is None + assert item.creator == mover + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"default_storage_limit": 1000}, +) +def test_api_items_move_file_to_root_under_quota(): + """A user with free storage can move a shared file to their root and become creator.""" + creator = factories.UserFactory() + mover = factories.UserFactory() + client = APIClient() + client.force_login(mover) + + folder = factories.ItemFactory( + creator=creator, + users=[(creator, models.RoleChoices.OWNER), (mover, models.RoleChoices.OWNER)], + type=models.ItemTypeChoices.FOLDER, + ) + item = factories.ItemFactory( + creator=creator, + parent=folder, + type=models.ItemTypeChoices.FILE, + size=100, + ) + + response = client.post(f"/api/v1.0/items/{item.id!s}/move/", data={}) + + assert response.status_code == 200 + item.refresh_from_db() + assert item.parent() is None + assert item.creator == mover + + def test_api_items_move_to_root_force_link_reach(): """ When moving an item to the root and no link_reach is set, force it to be restricted. From e50ace270e40adf49102d991af6a7917b2a34314 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:35:20 +0200 Subject: [PATCH 07/19] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20storage=20?= =?UTF-8?q?gauge=20information=20link=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators may document how storage quotas work for their users. The optional FRONTEND_STORAGE_GAUGE_INFORMATION_LINK setting is exposed through the config API so the frontend gauge can link to that page. --- src/backend/core/api/viewsets.py | 1 + src/backend/core/tests/test_api_config.py | 2 ++ src/backend/drive/settings.py | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8f79d4c80..a5f82ea37 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2290,6 +2290,7 @@ def get(self, request): "FRONTEND_EXTERNAL_HOME_URL", "FRONTEND_RELEASE_NOTE_ENABLED", "FRONTEND_ENTITLEMENTS_DISCLAIMERS", + "FRONTEND_STORAGE_GAUGE_INFORMATION_LINK", "FRONTEND_CSS_URL", "FRONTEND_JS_URL", "MEDIA_BASE_URL", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 7e8bd1be9..c3ab85940 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -47,6 +47,7 @@ THEME_CUSTOMIZATION_FILE_PATH="", FRONTEND_EXTERNAL_HOME_URL="https://test.com", FRONTEND_ENTITLEMENTS_DISCLAIMERS={}, + FRONTEND_STORAGE_GAUGE_INFORMATION_LINK="https://test.com/storage", ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -85,6 +86,7 @@ def test_api_config(is_authenticated): "FRONTEND_CSS_URL": "http://testcss/", "FRONTEND_JS_URL": "http://testjs/", "FRONTEND_ENTITLEMENTS_DISCLAIMERS": {}, + "FRONTEND_STORAGE_GAUGE_INFORMATION_LINK": "https://test.com/storage", "LANGUAGES": [ ["en-us", "English"], ["fr-fr", "French"], diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 50839fa5d..977d5bea7 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -1052,6 +1052,11 @@ class Base(Configuration): FRONTEND_ENTITLEMENTS_DISCLAIMERS = values.DictValue( {}, environ_name="FRONTEND_ENTITLEMENTS_DISCLAIMERS", environ_prefix=None ) + FRONTEND_STORAGE_GAUGE_INFORMATION_LINK = values.Value( + None, + environ_name="FRONTEND_STORAGE_GAUGE_INFORMATION_LINK", + environ_prefix=None, + ) FRONTEND_CSS_URL = values.Value(None, environ_name="FRONTEND_CSS_URL", environ_prefix=None) FRONTEND_JS_URL = values.Value(None, environ_name="FRONTEND_JS_URL", environ_prefix=None) THEME_CUSTOMIZATION_FILE_PATH = values.Value( From 5ed2639e6103b751572992e1b0599a6ce654123c Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:35:29 +0200 Subject: [PATCH 08/19] =?UTF-8?q?=E2=AC=86=EF=B8=8F(frontend)=20upgrade=20?= =?UTF-8?q?ui-kit=20to=200.27.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the StorageGaugeButton and StorageGaugeInformation components needed to render the storage quota gauge. --- src/frontend/apps/drive/package.json | 2 +- src/frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/apps/drive/package.json b/src/frontend/apps/drive/package.json index 65c7c82c8..8f1deefad 100644 --- a/src/frontend/apps/drive/package.json +++ b/src/frontend/apps/drive/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@gouvfr-lasuite/cunningham-react": "4.3.0", - "@gouvfr-lasuite/ui-kit": "0.26.0", + "@gouvfr-lasuite/ui-kit": "0.27.0", "@tanstack/react-query": "5.90.10", "@tanstack/react-table": "8.21.3", "@viselect/react": "3.9.0", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index c6697ac1f..da4291981 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -742,10 +742,10 @@ resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234" integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg== -"@gouvfr-lasuite/ui-kit@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.26.0.tgz#46109f95c707b8754f1a0146aa6503fa695cde0a" - integrity sha512-rSP6P7Wh+ioprHIm6VSspm78jBF1WCCJKRCNuTJTt52es85AzT2jrqxSE2FtLGJL67GNrMxT3kF/3DDg2wiH8A== +"@gouvfr-lasuite/ui-kit@0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.27.0.tgz#a8c51a6eed016e6c1dce55484b10c9ae74f751a5" + integrity sha512-6JKpbT+JGr5oJQHKkT0DiLcy/wMyuHFjmzvS++lpaslRHbPU7O3JYbkt9ilYW/r0wyNc6BRLN8ERpNmgUP3DEQ== dependencies: "@dnd-kit/core" "6.3.1" "@dnd-kit/modifiers" "9.0.0" From 027433253f67633e7cb72319c8edd09c9853e1c9 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:36:20 +0200 Subject: [PATCH 09/19] =?UTF-8?q?=E2=9C=A8(frontend)=20show=20specific=20q?= =?UTF-8?q?uota=20messages=20on=20rejected=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploads, moves to the root and duplications can now be refused by the backend quota gates. The API error code carries the can_upload reason, so each surface (upload list, move toast, duplicate toast) maps it to a dedicated translated message instead of a generic failure, and the 40x redirect is disabled on those calls so the user stays in place and sees the toast. The move mutation handles its own error feedback to avoid double toasting through the global handler. --- .../apps/drive/src/features/api/APIError.ts | 7 ++ .../apps/drive/src/features/drivers/Driver.ts | 3 + .../drivers/implementations/StandardDriver.ts | 44 ++++++-- .../disclaimers/CannotUploadDisclaimer.tsx | 31 +++--- .../features/entitlement-disclaimers/types.ts | 2 +- .../src/features/explorer/api/useMoveItem.tsx | 25 +++++ .../components/ExplorerDndProvider.tsx | 30 +++--- .../modals/move/ExplorerMoveFolderModal.tsx | 4 +- .../explorer/hooks/useItemActionMenuItems.tsx | 8 +- .../features/explorer/hooks/useMutations.ts | 4 + .../src/features/explorer/hooks/useUpload.tsx | 14 ++- .../drive/src/features/i18n/translations.json | 102 ++++++++++++++++-- .../apps/drive/src/utils/entitlements.ts | 28 +++++ .../app-drive/duplicate-item.spec.ts | 35 ++++++ .../e2e/__tests__/app-drive/move-item.spec.ts | 90 ++++++++++++++++ 15 files changed, 366 insertions(+), 61 deletions(-) diff --git a/src/frontend/apps/drive/src/features/api/APIError.ts b/src/frontend/apps/drive/src/features/api/APIError.ts index 8942ebf0e..2fa7dc24f 100644 --- a/src/frontend/apps/drive/src/features/api/APIError.ts +++ b/src/frontend/apps/drive/src/features/api/APIError.ts @@ -13,6 +13,13 @@ export class APIError extends Error { } } +export const errorToCode = (error: unknown): string | undefined => { + if (error instanceof APIError) { + return error.data?.errors?.[0]?.code; + } + return undefined; +}; + export const errorToString = (error: unknown): string => { if (typeof error === "string") { return error; diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index b083a7923..25d34530e 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -77,6 +77,9 @@ export type Entitlement = { export enum EntitlementCanUploadReasons { NO_ORGANIZATION = "no_organization", NOT_ACTIVATED = "not_activated", + USER_QUOTA_EXCEEDED = "user_quota_excedeed", + USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_excedeed", + ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_excedeed", } type EntitlementOperator = { diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index eb554e15d..ea3b19325 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -163,10 +163,18 @@ export class StandardDriver extends Driver { const payload = { ...(parentId ? { target_item_id: parentId } : {}), }; - await fetchAPI(`items/${id}/move/`, { - method: "POST", - body: JSON.stringify(payload), - }); + await fetchAPI( + `items/${id}/move/`, + { + method: "POST", + body: JSON.stringify(payload), + }, + { + // A move can be rejected (e.g. quota gate): let the caller toast the + // error instead of redirecting the whole app to the 403 page. + redirectOn40x: false, + }, + ); } async getItemAccesses(itemId: string): Promise { @@ -421,10 +429,16 @@ export class StandardDriver extends Driver { throw new DOMException("Upload cancelled", "AbortError"); } - await fetchAPI(`items/${item.id}/upload-ended/`, { - method: "POST", - signal: abortController.signal, - }); + await fetchAPI( + `items/${item.id}/upload-ended/`, + { + method: "POST", + signal: abortController.signal, + }, + { + redirectOn40x: false, + }, + ); progressHandler?.(100); @@ -462,9 +476,17 @@ export class StandardDriver extends Driver { } async duplicateItem(id: string): Promise { - const response = await fetchAPI(`items/${id}/duplicate/`, { - method: "POST", - }); + const response = await fetchAPI( + `items/${id}/duplicate/`, + { + method: "POST", + }, + { + // A duplication can be rejected (e.g. quota gate): let the global + // handler toast the error instead of redirecting to the 403 page. + redirectOn40x: false, + }, + ); return jsonToItem(await response.json()); } diff --git a/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx b/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx index ce57cc8b2..5ada8aaa6 100644 --- a/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx +++ b/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx @@ -51,24 +51,12 @@ const Content = ({ entitlements: Entitlements; }) => { const { t } = useTranslation(); + const reasonTitle = getCannotUploadReasonDescription( + entitlements.can_upload.reason, + ); return (
- {entitlements.can_upload.reason === - EntitlementCanUploadReasons.NOT_ACTIVATED && ( -

- {t( - "entitlements.disclaimers.cannot_upload.not_activated.description", - )} -

- )} - {entitlements.can_upload.reason === - EntitlementCanUploadReasons.NO_ORGANIZATION && ( -

- {t( - "entitlements.disclaimers.cannot_upload.no_organization.description", - )} -

- )} + {reasonTitle &&

{reasonTitle}

} {config?.showPotentialOperators && (entitlements.context?.potentialOperators?.length ?? 0) > 0 && (
@@ -106,4 +94,15 @@ const Content = ({ ); }; +export const getCannotUploadReasonDescription = ( + reason?: EntitlementCanUploadReasons, +) => { + if (reason) { + return i18n.t( + `entitlements.disclaimers.cannot_upload.${reason}.description`, + ); + } + return undefined; +}; + export default CannotUploadDisclaimer; diff --git a/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts b/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts index 4a3571988..01e9d7493 100644 --- a/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts +++ b/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts @@ -12,7 +12,7 @@ export type EntitlementDisclaimer< name: Name; show: (entitlements: Entitlements) => boolean; render: ( - config: DisclaimersConfig[Name], + config: DisclaimersConfig[Name] | undefined, entitlements: Entitlements, ) => { title: string; diff --git a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx index 45b76d83d..9cc15186a 100644 --- a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx @@ -1,5 +1,11 @@ import { getDriver } from "@/features/config/Config"; +import { getCanUploadErrorDescription } from "@/utils/entitlements"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + addToast, + ToasterItem, +} from "@/features/ui/components/toaster/Toaster"; import { useRemoveItemsFromPaginatedList } from "../hooks/useOptimisticPagination"; import { getMyFilesQueryKey, @@ -14,6 +20,7 @@ export const useMoveItems = () => { oldParentId?: string; }; + const { t } = useTranslation(); const queryClient = useQueryClient(); const driver = getDriver(); @@ -51,6 +58,24 @@ export const useMoveItems = () => { queryClient.invalidateQueries({ queryKey: ["items", variables.parentId, "children", "infinite"], }); + + addToast( + + arrow_forward + + {getCanUploadErrorDescription(err, (key) => + t(`explorer.modal.move.errors.${key}`), + ) ?? + t("explorer.actions.move.toast_error", { + count: variables.ids.length, + })} + + , + ); + }, + meta: { + // The onError above already toasts a localized message. + noGlobalError: true, }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx index 0447f6e27..c5d566ba1 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx @@ -116,20 +116,24 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { setOveredItemIds({}); const ids = currentSelected.map((item) => item.id); - await moveItems.mutateAsync( - { - ids: ids, - parentId: newParentId, - oldParentId: itemId, - }, - { - onSuccess: () => { - addItemsMovedToast(ids.length); - // Reset the selected items after the move - setSelectedItems([]); + await moveItems + .mutateAsync( + { + ids: ids, + parentId: newParentId, + oldParentId: itemId, }, - }, - ); + { + onSuccess: () => { + addItemsMovedToast(ids.length); + // Reset the selected items after the move + setSelectedItems([]); + }, + }, + ) + .catch(() => { + // The error feedback is already handled by the mutation's onError. + }); }; const handleDragEnd = async ({ active, over }: DragEndEvent) => { diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx index 123a6ef07..fb53edd3a 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx @@ -131,7 +131,9 @@ export const ExplorerMoveFolder = ({ newParentId: string | undefined, oldParentId: string, ) => { - moveItems.mutateAsync( + // mutate (not mutateAsync): a rejection is already handled by the + // mutation's onError, an unused rejected promise would be unhandled. + moveItems.mutate( { ids: ids, parentId: newParentId, diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx index 0bb2b74b6..38364d83e 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx @@ -34,6 +34,7 @@ import { useMutationDuplicateItem, } from "./useMutations"; import { DefaultRoute } from "@/utils/defaultRoutes"; +import { getCanUploadErrorDescription } from "@/utils/entitlements"; import { addToast, ToasterItem, @@ -179,11 +180,14 @@ export const useItemActionMenuItems = ({ callback: async () => { try { await duplicateItem(effectiveItemId); - } catch { + } catch (err) { addToast( content_copy - {t("explorer.item.actions.duplicate_error")} + + {getCanUploadErrorDescription(err) ?? + t("explorer.item.actions.duplicate_error")} + , ); } diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts index e05a92b74..389ee15d3 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts @@ -231,6 +231,10 @@ export const useMutationDuplicateItem = () => { const parentId = item?.originalId ?? item?.id; refresh(parentId); }, + meta: { + // The caller already toasts a localized message on failure. + noGlobalError: true, + }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx index 69b114c46..1f95adeae 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx @@ -16,7 +16,7 @@ import { useCanCreateChildren } from "@/features/items/utils"; import { getMyFilesQueryKey } from "@/utils/defaultRoutes"; import { useConfig } from "@/features/config/ConfigProvider"; import { getDriver } from "@/features/config/Config"; -import { APIError } from "@/features/api/APIError"; +import { errorToCode } from "@/features/api/APIError"; import { useRefreshQueryCacheAfterMutation } from "./useRefreshItems"; import { isIdInItemTree } from "../utils/utils"; @@ -33,6 +33,7 @@ import { customGetFilesFromEvent, isEmptyFolderMarker, } from "@/features/explorer/utils/dropTraversal"; +import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer"; type FileUpload = FileWithPath & { parentId?: string; @@ -421,10 +422,12 @@ export const useUploadZone = ({ item }: { item: Item }) => { ...prev, step: UploadingStep.NONE, })); + const description = getCannotUploadReasonDescription(entitlements.can_upload.reason); addToast( {entitlements.can_upload.message || + description || t("entitlements.can_upload.cannot_upload")} , @@ -618,10 +621,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { // Already handled by onCancelFile/onCancelAll continue; } - let errorCode = "unknown"; - if (err instanceof APIError && err.data?.errors?.[0]?.code) { - errorCode = err.data.errors[0].code; - } + const errorCode = errorToCode(err) ?? "unknown"; // Keep file in state with error status setUploadingState((prev) => ({ @@ -705,9 +705,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { // Collect matches first: onCancelFile mutates the map we're iterating. const toCancel: string[] = []; for (const [filePath, upload] of activeUploadsRef.current) { - if ( - deletedIds.some((id) => isIdInItemTree(upload.parentPath, id)) - ) { + if (deletedIds.some((id) => isIdInItemTree(upload.parentPath, id))) { toCancel.push(filePath); } } diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index f0dd74bc7..ded6e6e0b 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -9,6 +9,15 @@ "close": "OK", "cannot_upload": { "title": "Uploads are unavailable", + "user_quota_excedeed": { + "description": "You can no longer add documents, your personal quota has been reached. Contact your administrator." + }, + "user_override_quota_excedeed": { + "description": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator." + }, + "organization_quota_excedeed": { + "description": "You can no longer add documents, your organization quota has been reached. Contact your administrator." + }, "no_organization": { "description": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access." }, @@ -157,7 +166,14 @@ "move_to_root": "Move to root", "aria_label": "Move folder modal", "description_one_item": "Choose the new location for {{name}}", - "description_multiple_items": "Choose the new location for the {{count}} selected elements" + "description_multiple_items": "Choose the new location for the {{count}} selected elements", + "errors": { + "user_quota_excedeed": "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.", + "user_override_quota_excedeed": "You can no longer move documents to the root, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_excedeed": "You can no longer move documents to the root, your organization quota has been reached. Contact your administrator.", + "no_organization": "Your account isn't linked to an organization, so you can't move documents to the root yet. Contact your administrator to get access.", + "not_activated": "File upload isn't activated for your organization yet, so you can't move documents to the root. Contact your administrator to enable it." + } } }, "drag_overlay": { @@ -484,10 +500,20 @@ "file_type_not_allowed": "This file type is not allowed.", "item_create_file_extension_not_allowed": "This file extension is not allowed.", "file_too_large": "The file exceeds the maximum allowed size.", + "user_quota_excedeed": "You can no longer add documents, your personal quota has been reached. Contact your administrator.", + "user_override_quota_excedeed": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_excedeed": "You can no longer add documents, your organization quota has been reached. Contact your administrator.", + "no_organization": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access.", + "not_activated": "File upload isn't activated for your organization yet. Contact your administrator to enable it.", "unknown": "An unexpected error occurred." }, "error_short": { - "file_too_large": "File too large" + "file_too_large": "File too large", + "user_quota_excedeed": "Quota exceeded", + "user_override_quota_excedeed": "Quota exceeded", + "organization_quota_excedeed": "Quota exceeded", + "no_organization": "Upload not allowed", + "not_activated": "Upload not allowed" } }, "cancel_modal": { @@ -510,7 +536,9 @@ }, "move": { "toast_one": "{{count}} item moved", - "toast_other": "{{count}} items moved" + "toast_other": "{{count}} items moved", + "toast_error_one": "An error occurred while moving the item.", + "toast_error_other": "An error occurred while moving the items." }, "share": { "modal": { @@ -656,6 +684,15 @@ "close": "OK", "cannot_upload": { "title": "Les téléversements ne sont pas disponibles", + "user_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur." + }, + "user_override_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur." + }, + "organization_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur." + }, "no_organization": { "description": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès." }, @@ -804,7 +841,14 @@ "move_to_root": "Déplacer vers la racine", "aria_label": "Modal de déplacement de dossier", "description_one_item": "Choisissez le nouvel emplacement pour {{name}}", - "description_multiple_items": "Choisissez le nouvel emplacement pour les {{count}} éléments sélectionnés" + "description_multiple_items": "Choisissez le nouvel emplacement pour les {{count}} éléments sélectionnés", + "errors": { + "user_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota d'organisation est atteint. Contactez votre administrateur.", + "no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore déplacer de documents à la racine. Contactez votre administrateur pour obtenir un accès.", + "not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Vous ne pouvez donc pas déplacer de documents à la racine. Contactez votre administrateur pour l'activer." + } } }, "drag_overlay": { @@ -1134,10 +1178,20 @@ "file_type_not_allowed": "Ce type de fichier n'est pas autorisé.", "item_create_file_extension_not_allowed": "Cette extension de fichier n'est pas autorisée.", "file_too_large": "Le fichier dépasse la taille maximale autorisée.", + "user_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur.", + "no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès.", + "not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Contactez votre administrateur pour l'activer.", "unknown": "Une erreur inattendue est survenue." }, "error_short": { - "file_too_large": "Fichier trop volumineux" + "file_too_large": "Fichier trop volumineux", + "user_quota_excedeed": "Quota atteint", + "user_override_quota_excedeed": "Quota atteint", + "organization_quota_excedeed": "Quota atteint", + "no_organization": "Téléversement non autorisé", + "not_activated": "Téléversement non autorisé" } }, "cancel_modal": { @@ -1164,7 +1218,9 @@ }, "move": { "toast_one": "{{count}} élément déplacé", - "toast_other": "{{count}} éléments déplacés" + "toast_other": "{{count}} éléments déplacés", + "toast_error_one": "Une erreur est survenue lors du déplacement de l'élément.", + "toast_error_other": "Une erreur est survenue lors du déplacement des éléments." }, "share": { "modal": { @@ -1311,6 +1367,15 @@ "close": "OK", "cannot_upload": { "title": "Uploaden is niet beschikbaar", + "user_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder." + }, + "user_override_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder." + }, + "organization_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder." + }, "no_organization": { "description": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang." }, @@ -1459,7 +1524,14 @@ "move_to_root": "Verplaats naar root", "aria_label": "Map verplaatsen modal", "description_one_item": "Kies de nieuwe locatie voor {{name}}", - "description_multiple_items": "Kies de nieuwe locatie voor de {{count}} geselecteerde elementen" + "description_multiple_items": "Kies de nieuwe locatie voor de {{count}} geselecteerde elementen", + "errors": { + "user_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.", + "no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen documenten naar de root verplaatsen. Neem contact op met uw beheerder voor toegang.", + "not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie, dus u kunt geen documenten naar de root verplaatsen. Neem contact op met uw beheerder om dit in te schakelen." + } } }, "drag_overlay": { @@ -1744,10 +1816,20 @@ "file_type_not_allowed": "Dit bestandstype is niet toegestaan.", "item_create_file_extension_not_allowed": "Deze bestandsextensie is niet toegestaan.", "file_too_large": "Het bestand overschrijdt de maximale toegestane grootte.", + "user_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_excedeed": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.", + "no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang.", + "not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie. Neem contact op met uw beheerder om dit in te schakelen.", "unknown": "Er is een onverwachte fout opgetreden." }, "error_short": { - "file_too_large": "Bestand te groot" + "file_too_large": "Bestand te groot", + "user_quota_excedeed": "Quotum bereikt", + "user_override_quota_excedeed": "Quotum bereikt", + "organization_quota_excedeed": "Quotum bereikt", + "no_organization": "Uploaden niet toegestaan", + "not_activated": "Uploaden niet toegestaan" } }, "cancel_modal": { @@ -1770,7 +1852,9 @@ }, "move": { "toast_one": "{{count}} item verplaatst", - "toast_other": "{{count}} items verplaatst" + "toast_other": "{{count}} items verplaatst", + "toast_error_one": "Er is een fout opgetreden bij het verplaatsen van het item.", + "toast_error_other": "Er is een fout opgetreden bij het verplaatsen van de items." }, "share": { "modal": { diff --git a/src/frontend/apps/drive/src/utils/entitlements.ts b/src/frontend/apps/drive/src/utils/entitlements.ts index 9073bbd46..6c035df32 100644 --- a/src/frontend/apps/drive/src/utils/entitlements.ts +++ b/src/frontend/apps/drive/src/utils/entitlements.ts @@ -1,6 +1,34 @@ import { getDriver } from "@/features/config/Config"; +import { errorToCode } from "@/features/api/APIError"; +import { EntitlementCanUploadReasons } from "@/features/drivers/Driver"; +import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer"; export const getEntitlements = async () => { const driver = getDriver(); return driver.getEntitlements(); }; + +/** + * The quota gates (upload, move, duplicate) expose the can_upload reason as the + * API error code: map it back to its localized description when known. + */ +export const getCanUploadErrorDescription = ( + error: unknown, + translate?: (key: string) => string, +): string | undefined => { + const code = errorToCode(error); + if ( + code && + Object.values(EntitlementCanUploadReasons).includes( + code as EntitlementCanUploadReasons, + ) + ) { + if (translate) { + return translate(code); + } + return getCannotUploadReasonDescription( + code as EntitlementCanUploadReasons, + ); + } + return undefined; +}; diff --git a/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts index 4179e9b49..ad740bbc6 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts @@ -121,6 +121,41 @@ test.describe("Duplicate item", () => { ).not.toBeVisible(); }); + test("Shows the specific quota message when the duplication is rejected", async ({ + page, + }) => { + await createFileFromTemplate(page, "TestDoc"); + + // Reject the duplication with the quota gate's reason code. + await page.route("**/api/v1.0/items/*/duplicate/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "user_quota_excedeed", + detail: "You do not have permission to upload files.", + attr: null, + }, + ], + }), + }), + ); + + await triggerDuplicate(page, "TestDoc"); + + await expect( + page.getByText( + "You can no longer add documents, your personal quota has been reached. Contact your administrator.", + ), + ).toBeVisible(); + await expect( + page.getByText("An error occurred while duplicating the item."), + ).not.toBeVisible(); + }); + test("Duplicate option is available for files but not folders", async ({ page, }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts index 2dcd346b3..5b3459d53 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts @@ -28,6 +28,96 @@ test("Move an item to a new folder", async ({ page }) => { await expect(JohnRow).not.toBeVisible(); }); +test("Show an error toast when the server rejects the move", async ({ + page, +}) => { + await clearDb(); + await login(page, "drive@example.com"); + await page.goto("/"); + await clickToMyFiles(page); + await createFolderInCurrentFolder(page, "John"); + await createFolderInCurrentFolder(page, "Doe"); + + // Reject the move server-side (e.g. quota gate on move-to-root). + await page.route("**/api/v1.0/items/*/move/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "permission_denied", + detail: "You cannot take ownership of more storage.", + attr: null, + }, + ], + }), + }), + ); + + const JohnRow = await getRowItem(page, "John"); + await clickOnRowItemActions(page, "John", "Move"); + const moveFolderModal = await getMoveFolderModal(page); + const DoeRow = await getRowItem(moveFolderModal, "Doe"); + await DoeRow.click(); + await acceptMoveItem(page); + + await expect( + page.getByText("An error occurred while moving the item."), + ).toBeVisible(); + + // The modal stays open on failure; close it and check the item stayed put. + await moveFolderModal.getByRole("button", { name: "Cancel" }).click(); + await expect(JohnRow).toBeVisible(); +}); + +test("Show the specific quota message when the move is rejected", async ({ + page, +}) => { + await clearDb(); + await login(page, "drive@example.com"); + await page.goto("/"); + await clickToMyFiles(page); + await createFolderInCurrentFolder(page, "John"); + await createFolderInCurrentFolder(page, "Doe"); + + // Reject the move with the quota gate's reason code. + await page.route("**/api/v1.0/items/*/move/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "user_quota_excedeed", + detail: "You cannot take ownership of more storage.", + attr: null, + }, + ], + }), + }), + ); + + const JohnRow = await getRowItem(page, "John"); + await clickOnRowItemActions(page, "John", "Move"); + const moveFolderModal = await getMoveFolderModal(page); + const DoeRow = await getRowItem(moveFolderModal, "Doe"); + await DoeRow.click(); + await acceptMoveItem(page); + + await expect( + page.getByText( + "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.", + ), + ).toBeVisible(); + + // The modal stays open on failure; close it and check the item stayed put. + await moveFolderModal.getByRole("button", { name: "Cancel" }).click(); + await expect(JohnRow).toBeVisible(); +}); + test("Search and select to move an item", async ({ page }) => { await clearDb(); await login(page, "drive@example.com"); From be6109c99f90b9638e93450fcf02d359854c0036 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 13 Jul 2026 14:36:50 +0200 Subject: [PATCH 10/19] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20the=20storage?= =?UTF-8?q?=20gauge=20and=20settings=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the quota returned by the entitlements API as a gauge in the left panel footer, with a locked state when the organization quota is reached and a tooltip carrying the error when the quota cannot be computed. Clicking it opens a settings modal with the detailed gauge and an optional documentation link. The footer is reworked to host the gauge on mobile too (user profile and app grid move there), and the entitlements query is refetched after uploads and hard deletes so the gauge follows the usage. --- .../apps/drive/src/features/drivers/Driver.ts | 7 + .../apps/drive/src/features/drivers/types.ts | 1 + .../explorer/components/tree/ExplorerTree.tsx | 7 +- .../explorer/hooks/useRefreshItems.ts | 19 +- .../src/features/explorer/hooks/useUpload.tsx | 14 +- .../utils/__tests__/formatSizeTo.test.ts | 35 ++ .../src/features/explorer/utils/utils.ts | 7 + .../drive/src/features/i18n/translations.json | 78 ++++ .../components/explorer/ExplorerLayout.scss | 21 + .../components/explorer/ExplorerLayout.tsx | 240 ++++++++++-- .../components/left-panel/LeftPanelMobile.tsx | 2 - .../e2e/__tests__/app-drive/help-menu.spec.ts | 5 +- .../__tests__/app-drive/storage-gauge.spec.ts | 363 ++++++++++++++++++ 13 files changed, 763 insertions(+), 36 deletions(-) create mode 100644 src/frontend/apps/drive/src/features/explorer/utils/__tests__/formatSizeTo.test.ts create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/storage-gauge.spec.ts diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 25d34530e..e4dc37320 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -105,6 +105,13 @@ export type Entitlements = { operator?: EntitlementOperator; potentialOperators?: EntitlementOperator[]; }; + quota?: { + state: "default" | "excedeed_locked" | "error"; + reason?: string; + error?: string; + usage?: number; + limit?: number; + }; }; export abstract class Driver { diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index b03d59cf1..0d1782c11 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -234,6 +234,7 @@ export type ApiConfig = { showPotentialOperators: boolean; }; }; + FRONTEND_STORAGE_GAUGE_INFORMATION_LINK?: string; FRONTEND_CSS_URL?: string; FRONTEND_JS_URL?: string; theme_customization?: ThemeCustomization; diff --git a/src/frontend/apps/drive/src/features/explorer/components/tree/ExplorerTree.tsx b/src/frontend/apps/drive/src/features/explorer/components/tree/ExplorerTree.tsx index 7d680502f..665dd2101 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/tree/ExplorerTree.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/tree/ExplorerTree.tsx @@ -27,7 +27,6 @@ import { addItemsMovedToast } from "../toasts/addItemsMovedToast"; import { ExplorerTreeMoveConfirmationModal } from "./ExplorerTreeMoveConfirmationModal"; import { canDrop } from "../ExplorerDndProvider"; import React from "react"; -import { LeftPanelMobile } from "@/features/layouts/components/left-panel/LeftPanelMobile"; import { useAuth } from "@/features/auth/Auth"; import { ExplorerTreeNavItem } from "./nav/ExplorerTreeNavItem"; import { useRouter } from "next/router"; @@ -111,7 +110,7 @@ export const ExplorerTree = () => { - + {initialOpenState && ( { /> )} -
- - -
{moveState && moveConfirmationModal.isOpen && ( { if (parentId) { queryKeys.push(["items", parentId, "children"]); } - // let queryKey = parentId ? ["items", parentId, "children"] : []; - // if (queryKeyForRoute.length > 0) { - // queryKey = queryKeyForRoute; - // } return queryKeys; }; }; +export const useRefreshEntitlementsQueryCache = () => { + const queryClient = useQueryClient(); + return () => { + queryClient.invalidateQueries({ + queryKey: ["entitlements"], + }); + }; +}; + export const useRefreshQueryCacheAfterMutation = () => { const queryClient = useQueryClient(); const getQueryKey = useGetQueryKeyToRefresh(); @@ -45,6 +50,7 @@ export const useDeleteMutationCallbacks = ( const getQueryKey = useGetQueryKeyToRefresh(); const removeItems = useRemoveItemsFromPaginatedList(); const queryKeys = defaultQueryKey ?? getQueryKey(parentId); + const refreshEntitlements = useRefreshEntitlementsQueryCache(); const onMutate = async (itemIds: string[]) => { const returnPreviousItems: Map = new Map(); @@ -79,6 +85,11 @@ export const useDeleteMutationCallbacks = ( queryKey: key, }); } + + // Means hard deleting. + if (!parentId) { + refreshEntitlements(); + } }; return { onMutate, onError, onSuccess }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx index 1f95adeae..41d95ac0f 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx @@ -17,7 +17,10 @@ import { getMyFilesQueryKey } from "@/utils/defaultRoutes"; import { useConfig } from "@/features/config/ConfigProvider"; import { getDriver } from "@/features/config/Config"; import { errorToCode } from "@/features/api/APIError"; -import { useRefreshQueryCacheAfterMutation } from "./useRefreshItems"; +import { + useRefreshEntitlementsQueryCache, + useRefreshQueryCacheAfterMutation, +} from "./useRefreshItems"; import { isIdInItemTree } from "../utils/utils"; type ActiveUpload = { @@ -240,6 +243,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { const driver = getDriver(); const refresh = useRefreshQueryCacheAfterMutation(); + const refreshEntitlements = useRefreshEntitlementsQueryCache(); const canCreateChildren = useCanCreateChildren(item); @@ -547,6 +551,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { // (abort === undefined means it hasn't started uploading yet). // Map iteration follows insertion order → FIFO is preserved. // The loop exits when no more queued entries remain. + let hasUploadedFiles = false; while (true) { let nextEntry: [string, ActiveUpload] | undefined; for (const entry of activeUploadsRef.current) { @@ -598,6 +603,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { } activeUploadsRef.current.delete(filePath); refresh(file.parentId); + hasUploadedFiles = true; setUploadingState((prev) => ({ ...prev, filesMeta: { @@ -641,6 +647,12 @@ export const useUploadZone = ({ item }: { item: Item }) => { isProcessingRef.current = false; + // The backend invalidates its entitlements cache when an upload ends, + // so a single refetch once the queue is drained reflects the whole batch. + if (hasUploadedFiles) { + refreshEntitlements(); + } + setUploadingState((prev) => ({ ...prev, step: UploadingStep.DONE, diff --git a/src/frontend/apps/drive/src/features/explorer/utils/__tests__/formatSizeTo.test.ts b/src/frontend/apps/drive/src/features/explorer/utils/__tests__/formatSizeTo.test.ts new file mode 100644 index 000000000..2451b5191 --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/utils/__tests__/formatSizeTo.test.ts @@ -0,0 +1,35 @@ +import { formatSizeTo } from "../utils"; + +describe("formatSizeTo", () => { + it("returns the input as is for the B unit", () => { + expect(formatSizeTo(1234, "B")).toBe(1234); + }); + + it("converts octets to KB", () => { + expect(formatSizeTo(1500, "KB")).toBe(1.5); + }); + + it("converts octets to MB", () => { + expect(formatSizeTo(1_500_000, "MB")).toBe(1.5); + }); + + it("converts octets to GB", () => { + expect(formatSizeTo(2_000_000_000, "GB")).toBe(2); + }); + + it("converts octets to TB", () => { + expect(formatSizeTo(1_000_000_000_000, "TB")).toBe(1); + }); + + it("converts octets to PB", () => { + expect(formatSizeTo(2_500_000_000_000_000, "PB")).toBe(2.5); + }); + + it("returns fractional values when the size is smaller than the unit", () => { + expect(formatSizeTo(500, "KB")).toBe(0.5); + }); + + it("returns 0 for an empty size", () => { + expect(formatSizeTo(0, "GB")).toBe(0); + }); +}); diff --git a/src/frontend/apps/drive/src/features/explorer/utils/utils.ts b/src/frontend/apps/drive/src/features/explorer/utils/utils.ts index 1aecd7634..7af811f86 100644 --- a/src/frontend/apps/drive/src/features/explorer/utils/utils.ts +++ b/src/frontend/apps/drive/src/features/explorer/utils/utils.ts @@ -125,6 +125,13 @@ export const getExtensionFromName = (str: string) => { const SIZE_UNIT_KEYS = ["B", "KB", "MB", "GB", "TB", "PB"] as const; +export type SizeUnit = (typeof SIZE_UNIT_KEYS)[number]; + +export const formatSizeTo = (octets: number, unit: SizeUnit) => { + const unitIndex = SIZE_UNIT_KEYS.indexOf(unit); + return octets / 1000 ** unitIndex; +}; + export const formatSize = (size: number, t?: (key: string) => string) => { let convertedSize = size; let unitIndex = 0; diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index ded6e6e0b..89a91954d 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -39,6 +39,32 @@ "title": "You don't have the necessary permissions to access this page.", "button": "Home" }, + "settings_modal": { + "title": "Settings", + "tabs": { + "storage": { + "title": "Storage" + } + } + }, + "quota": { + "gauge": { + "exceeded_locked": { + "title": "Organization quota exceeded", + "label": "Contact an administrator to increase the quota", + "reason": { + "organization_quota_excedeed": { + "description": "Org. quota exceeded" + } + } + }, + "error": { + "title": "Quota error", + "label": "Contact support to resolve the error", + "tooltip": "Error: {{error}}" + } + } + }, "share_modal": { "options": { "link_reach": { @@ -714,6 +740,32 @@ "title": "Vous n'avez pas les permissions nécessaires pour accéder à cette page.", "button": "Accueil" }, + "settings_modal": { + "title": "Paramètres", + "tabs": { + "storage": { + "title": "Stockage" + } + } + }, + "quota": { + "gauge": { + "exceeded_locked": { + "title": "Quota organisation dépassé", + "label": "Contactez un administrateur pour augmenter le quota", + "reason": { + "organization_quota_excedeed": { + "description": "Quota org. dépassé" + } + } + }, + "error": { + "title": "Erreur de quota", + "label": "Contactez le support pour résoudre l'erreur", + "tooltip": "Erreur: {{error}}" + } + } + }, "share_modal": { "options": { "link_reach": { @@ -1397,6 +1449,32 @@ "title": "U beschikt niet over de benodigde rechten om dit bestand te openen.", "button": "Home" }, + "settings_modal": { + "title": "Instellingen", + "tabs": { + "storage": { + "title": "Opslag" + } + } + }, + "quota": { + "gauge": { + "exceeded_locked": { + "title": "Organisatiequotum overschreden", + "label": "Neem contact op met een beheerder om het quotum te verhogen", + "reason": { + "organization_quota_excedeed": { + "description": "Org. quotum overschreden" + } + } + }, + "error": { + "title": "Quotafout", + "label": "Neem contact op met de support om de fout op te lossen", + "tooltip": "Fout: {{error}}" + } + } + }, "share_modal": { "options": { "link_reach": { diff --git a/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.scss b/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.scss index 910de36da..437854866 100644 --- a/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.scss +++ b/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.scss @@ -1,6 +1,8 @@ .c__left-panel__footer__drive { padding: 12px; display: flex; + gap: 4px; + align-items: center; } // Toast component from cunningham-react is not used in the drive app, so we hide it. @@ -8,3 +10,22 @@ .c__toast__container { display: none; } + +.c__storage-gauge__locked-content { + display: flex; + align-items: center; + gap: 4px; + flex-grow: 1; + justify-content: center; +} + +.c__storage-gauge-information__locked-content { + display: flex; + align-items: center; + gap: 4px; + flex-grow: 1; +} + +.c__tooltip__content { + word-break: break-word; +} diff --git a/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.tsx b/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.tsx index f4c3019a3..741223d9e 100644 --- a/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.tsx +++ b/src/frontend/apps/drive/src/features/layouts/components/explorer/ExplorerLayout.tsx @@ -1,7 +1,14 @@ import { useAuth } from "@/features/auth/Auth"; import { useConfig } from "@/features/config/ConfigProvider"; import { ExplorerTree } from "@/features/explorer/components/tree/ExplorerTree"; -import { HelpMenu, MainLayout } from "@gouvfr-lasuite/ui-kit"; +import { + HelpMenu, + IconSize, + MainLayout, + StorageGaugeButton, + StorageGaugeInformation, + useResponsive, +} from "@gouvfr-lasuite/ui-kit"; import { HeaderIcon, HeaderRight } from "../header/Header"; import { GlobalExplorerProvider, @@ -15,9 +22,28 @@ import { useRouter } from "next/router"; import { useSyncUserLanguage } from "../../hooks/useSyncUserLanguage"; import { Item } from "@/features/drivers/types"; import { ReleaseNoteAuto } from "@/features/ui/components/release-note"; -import { setManualNavigationItemId } from "@/features/explorer/utils/utils"; +import { + formatSizeTo, + setManualNavigationItemId, +} from "@/features/explorer/utils/utils"; import { ColumnPreferencesProvider } from "@/features/explorer/hooks/useColumnPreferences"; import { EntitlementDisclaimers } from "@/features/entitlement-disclaimers/EntitlementDisclaimers"; +import { useEntitlements } from "@/features/entitlement-disclaimers/hooks/useEntitlements"; +import { Info, Warning } from "@gouvfr-lasuite/ui-kit/icons"; +import { + Button, + Modal, + ModalProps, + ModalSize, + ModalTab, + Tooltip, + useModal, +} from "@gouvfr-lasuite/cunningham-react"; +import { useTranslation } from "react-i18next"; +import i18n from "@/features/i18n/initI18n"; +import { useMemo } from "react"; +import { UserProfile } from "@/features/ui/components/user/UserProfile"; +import { Gaufre } from "@/features/ui/components/gaufre/Gaufre"; export const getGlobalExplorerLayout = (page: React.ReactElement) => { return {page}; @@ -97,11 +123,6 @@ export const ExplorerPanelsLayout = ({ } = useGlobalExplorer(); const { user } = useAuth(); - const { config } = useConfig(); - - const helpMenuConfig = config?.FRONTEND_HELP_MENU_CONFIG; - const hasHelpMenu = - !!helpMenuConfig && Object.keys(helpMenuConfig).length > 0; return ( setRightPanelOpen(!rightPanelOpen)} leftPanelContent={user ? : } - leftPanelFooter={ - hasHelpMenu ? ( -
- window.open(helpMenuConfig.supportEmail) - : undefined - } - /> -
- ) : undefined - } + leftPanelFooter={} isLeftPanelOpen={isLeftPanelOpen} hideLeftPanelOnDesktop={!user || isMinimalLayout} setIsLeftPanelOpen={() => setIsLeftPanelOpen(!isLeftPanelOpen)} @@ -137,3 +144,192 @@ export const ExplorerPanelsLayout = ({
); }; + +const LeftPanelFooter = () => { + const { isTablet } = useResponsive(); + const settingsModal = useModal(); + + return ( + <> + {isTablet ? ( + + ) : ( + + )} + + + + ); +}; + +type LeftPanelFooterProps = { + openSettingsModal: () => void; +}; + +const LeftPanelFooterMobile = (props: LeftPanelFooterProps) => { + return ( +
+ + + + +
+ ); +}; + +const LeftPanelFooterDesktop = (props: LeftPanelFooterProps) => { + return ( +
+ + +
+ ); +}; + +const HelpMenuButton = () => { + const { config } = useConfig(); + const helpMenuConfig = config?.FRONTEND_HELP_MENU_CONFIG; + const hasHelpMenu = + !!helpMenuConfig && Object.keys(helpMenuConfig).length > 0; + + if (!hasHelpMenu) { + return null; + } + + return ( + window.open(helpMenuConfig.supportEmail) + : undefined + } + /> + ); +}; + +const SettingsModal = (props: Pick) => { + const { t } = useTranslation(); + const tabs: ModalTab[] = [ + { + id: "tab1", + label: i18n.t("settings_modal.tabs.storage.title"), + title: i18n.t("settings_modal.tabs.storage.title"), + content: , + }, + ]; + + return ( + + ); +}; + +const SettingsModalStorageTab = () => { + const { config } = useConfig(); + const storageGauge = useStorageGauge(); + if (!storageGauge) { + return null; + } + const informationLink = config?.FRONTEND_STORAGE_GAUGE_INFORMATION_LINK; + return ( + window.open(informationLink, "_blank") + : undefined + } + /> + ); +}; + +const LeftPanelFooterStorageGauge = (props: { onClick: () => void }) => { + const storageGauge = useStorageGauge(); + if (!storageGauge) { + return null; + } + return ; +}; + +const useStorageGauge = () => { + const { data: entitlements } = useEntitlements(); + const { t } = useTranslation(); + + const quota = useMemo(() => { + const quota = entitlements?.quota; + if (!quota) { + return null; + } + if (quota.state === "default") { + const usageFormatted = formatSizeTo(quota.usage!, "GB"); + const limitFormatted = formatSizeTo(quota.limit!, "GB"); + return { + quota: quota, + used: usageFormatted, + total: limitFormatted, + }; + } else if (quota.state === "excedeed_locked") { + return { + quota: quota, + used: 0, + total: 0, + locked: true, + // For button gauge. + lockedContent: ( + + {" "} + {t( + `quota.gauge.exceeded_locked.reason.${quota.reason}.description`, + )} + + ), + // For information gauge. + title: t("quota.gauge.exceeded_locked.title"), + label: t("quota.gauge.exceeded_locked.label"), + }; + } else if (quota.state === "error") { + return { + quota: quota, + used: 0, + total: 0, + locked: true, + // For button gauge. + lockedContent: ( + + + {t("quota.gauge.error.title")} + + + ), + // For information gauge. + title: t("quota.gauge.error.title"), + label: t("quota.gauge.error.label"), + labelChildren: ( + +