diff --git a/CHANGELOG.md b/CHANGELOG.md index 624c18f09..c8cc0740a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to - ✨(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 +- ✨(backend) add a local entitlements backend with per-user storage limits +- ✨(frontend) add storage gauge and settings modal ### Fixed 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/api/serializers.py b/src/backend/core/api/serializers.py index d24544aeb..cbddbf8d3 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -88,19 +88,19 @@ def to_representation(self, instance): return output -class OrganizationUsageMetricSerializer(serializers.Serializer): +# pylint: disable=abstract-method +class OrganizationUsageMetricSerializer(serializers.BaseSerializer): """Serialize aggregated usage metrics for an organization.""" - account_id_key = serializers.CharField() - account_id_value = serializers.CharField() - total_storage = serializers.IntegerField() - def to_representation(self, instance): """Return the organization usage metric.""" + storage_compute_backend = get_storage_compute_backend() return { "account": {"type": "organization"}, instance["account_id_key"]: instance["account_id_value"], - "metrics": {"storage_used": instance["total_storage"]}, + "metrics": { + "storage_used": storage_compute_backend.compute_storage_used(instance["users"]) + }, } diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 54bbbe335..bf9d6ba1f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -53,7 +53,7 @@ get_file_indexer, 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 @@ -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 @@ -1014,17 +1015,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, ) + # Saving the item 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") @@ -1089,7 +1106,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) @@ -1707,6 +1725,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: @@ -2262,6 +2289,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", @@ -2406,14 +2434,11 @@ def _list_organization(self, request): raise drf.exceptions.ValidationError(filterset.errors) users = filterset.filter_queryset(base_qs) - storage_backend = get_storage_compute_backend() - total_storage = storage_backend.compute_storage_used(users) - serializer = serializers.OrganizationUsageMetricSerializer( { "account_id_key": filterset.form.cleaned_data["account_id_key"], "account_id_value": filterset.form.cleaned_data["account_id_value"], - "total_storage": total_storage, + "users": users, } ) @@ -2443,5 +2468,7 @@ def list(self, request): method = getattr(entitlements_backend, method_name) if callable(method): entitlements[method_name] = method(request.user) + if quota := entitlements_backend.get_quota(request.user): + 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..a5b69193a 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" + EXCEEDED_LOCKED = "exceeded_locked" + ERROR = "error" + + +class QuotaReason(StrEnum): + """Reasons explaining why the quota gauge is locked (get_quota output).""" + + ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_exceeded" + + +class CanUploadReason(StrEnum): + """Reasons explaining why a user cannot upload (can_upload output).""" + + NO_ORGANIZATION = "no_organization" + NOT_ACTIVATED = "not_activated" + USER_QUOTA_EXCEEDED = "user_quota_exceeded" + USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_exceeded" + ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_exceeded" + + +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,10 @@ 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 {} + + 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 88f91806f..335a2aaeb 100644 --- a/src/backend/core/entitlements/backends/deploycenter.py +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -6,11 +6,21 @@ import requests -from core.entitlements.backends.base import EntitlementsBackend +from core.api.serializers import ( + OrganizationUsageMetricSerializer, + UserUsageMetricSerializer, +) +from core.entitlements.backends.base import ( + CanUploadReason, + EntitlementsBackend, + QuotaError, + QuotaReason, + QuotaState, +) +from core.models import User logger = logging.getLogger(__name__) -ENTITLEMENTS_CACHE_TIMEOUT = 60 ENTITLEMENTS_CACHE_KEY_PREFIX = "entitlements:user:" @@ -18,12 +28,40 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend): """Entitlements backend that checks permissions via a DeployCenter service.""" # pylint: disable-next=too-many-arguments,too-many-positional-arguments - def __init__(self, base_url, service_id, api_key, cache_timeout=10, oidc_claims=None): + def __init__( # noqa: PLR0913 + self, + base_url, + service_id, + api_key, + cache_timeout=10, + oidc_claims=None, + organization_claim="siret", + ): self.base_url = base_url self.service_id = service_id self.api_key = api_key self.cache_timeout = cache_timeout self.oidc_claims = oidc_claims or [] + self.organization_claim = organization_claim + + def build_usage_metrics(self, user): + """Build the usage metric entries pushed to the DeployCenter service.""" + user_entry = dict(UserUsageMetricSerializer(user).data) + organization_value = user.claims.get(self.organization_claim) + if organization_value is None: + return [user_entry] + user_entry[self.organization_claim] = organization_value + organization_users = User.objects.filter( + is_active=True, **{f"claims__{self.organization_claim}": organization_value} + ) + organization_entry = OrganizationUsageMetricSerializer( + { + "account_id_key": self.organization_claim, + "account_id_value": organization_value, + "users": organization_users, + } + ).data + return [user_entry, organization_entry] def fetch_entitlements(self, user): """Fetch entitlements for a user from the DeployCenter service.""" @@ -37,9 +75,10 @@ def fetch_entitlements(self, user): if value is not None: params[claim] = value - response = requests.get( + response = requests.post( self.base_url, params=params, + json={"usage_metrics": self.build_usage_metrics(user)}, headers={"X-Service-Auth": f"Bearer {self.api_key}"}, timeout=10, ) @@ -60,6 +99,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"] @@ -72,12 +115,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_EXCEEDED + elif resolve_level == "user_override": + actual_reason = CanUploadReason.USER_OVERRIDE_QUOTA_EXCEEDED + elif resolve_level == "organization": + actual_reason = CanUploadReason.ORGANIZATION_QUOTA_EXCEEDED + 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.EXCEEDED_LOCKED, + "reason": QuotaReason.ORGANIZATION_QUOTA_EXCEEDED, + } + + 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/entitlements/backends/local.py b/src/backend/core/entitlements/backends/local.py new file mode 100644 index 000000000..ee0f95123 --- /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_EXCEEDED + if override_set + else CanUploadReason.USER_QUOTA_EXCEEDED + ), + "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/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 ad065b2c9..993f8502f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -42,11 +42,15 @@ 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 logger = getLogger(__name__) +# Item fields whose update can change the storage used by a user. +STORAGE_USED_FIELDS = {"size", "creator", "creator_id", "hard_deleted_at"} + def get_trashbin_cutoff(): """ @@ -255,6 +259,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, @@ -317,6 +334,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 Item.save() invalidating the + # storage used cache. + transaction.on_commit(lambda: invalidate_storage_used_cache([self.id])) valid_invitations.delete() @@ -497,6 +517,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 Item.save() 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"]) @@ -1025,6 +1050,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): @@ -1073,7 +1105,21 @@ def save(self, *args, **kwargs): if not self.path: self.path = str(self.id) - return super().save(*args, **kwargs) + super().save(*args, **kwargs) + + self._invalidate_storage_used_cache(kwargs.get("update_fields")) + + def _invalidate_storage_used_cache(self, update_fields): + """ + Invalidate the creator's cached storage usage when a save may have + changed it. Bulk queryset updates bypass save() and must invalidate + the cache explicitly. + """ + if update_fields and STORAGE_USED_FIELDS.isdisjoint(update_fields): + return + if not self.creator_id: + return + transaction.on_commit(lambda: invalidate_storage_used_cache([self.creator_id])) def delete(self, using=None, keep_parents=False): if self.deleted_at is None and self.ancestors_deleted_at is None: @@ -1473,12 +1519,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 Item.save() 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/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/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/items/test_api_item_upload_ended.py b/src/backend/core/tests/items/test_api_item_upload_ended.py index 0594d47ae..99a02c79f 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_exceeded"]) @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..2e860d756 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_exceeded"]) @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, } 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..88b84d4c1 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_exceeded" + 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..b1f871e49 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_exceeded" + + # 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. 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/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 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/core/tests/test_api_entitlements_deploycenter.py b/src/backend/core/tests/test_api_entitlements_deploycenter.py index 280335b94..43882e041 100644 --- a/src/backend/core/tests/test_api_entitlements_deploycenter.py +++ b/src/backend/core/tests/test_api_entitlements_deploycenter.py @@ -2,16 +2,26 @@ Test Entitlements API endpoints with DeployCenter entitlements backend. """ +import json 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 @@ -53,13 +63,19 @@ def test_api_entitlements_deploycenter_get_entitlements_anonymous(): def test_api_entitlements_deploycenter_get_entitlements_both_true(): """Authenticated users should get entitlements when both can_access and can_upload are True.""" responses.add( - responses.GET, + responses.POST, ENTITLEMENTS_URL, json={ "entitlements": { "can_access": True, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -77,6 +93,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, @@ -92,6 +113,21 @@ def test_api_entitlements_deploycenter_get_entitlements_both_true(): assert responses.calls[0].request.headers["X-Service-Auth"] == ( f"Bearer {ENTITLEMENTS_BACKEND_PARAMETERS['api_key']}" ) + assert responses.calls[0].request.headers["Content-Type"] == "application/json" + assert json.loads(responses.calls[0].request.body) == { + "usage_metrics": [ + { + "account": {"type": "user", "id": user.sub, "email": user.email}, + "siret": "21140001500015", + "metrics": {"storage_used": 0}, + }, + { + "account": {"type": "organization"}, + "siret": "21140001500015", + "metrics": {"storage_used": 0}, + }, + ], + } @override_settings( @@ -102,7 +138,7 @@ def test_api_entitlements_deploycenter_get_entitlements_both_true(): def test_api_entitlements_deploycenter_get_entitlements_can_upload_false(): """Authenticated users should get correct entitlements when can_upload is False.""" responses.add( - responses.GET, + responses.POST, ENTITLEMENTS_URL, json={ "entitlements": { @@ -153,13 +189,19 @@ def test_api_entitlements_deploycenter_get_entitlements_can_upload_false(): def test_api_entitlements_deploycenter_get_entitlements_can_access_false(): """Authenticated users should get correct entitlements when can_access is False.""" responses.add( - responses.GET, + responses.POST, ENTITLEMENTS_URL, json={ "entitlements": { "can_access": False, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -177,6 +219,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, @@ -202,13 +249,19 @@ def test_api_entitlements_deploycenter_get_entitlements_can_access_false(): def test_api_entitlements_deploycenter_get_entitlements_cache(): """Authenticated users should get entitlements from cache when doing subsequent requests.""" responses.add( - responses.GET, + responses.POST, ENTITLEMENTS_URL, json={ "entitlements": { "can_access": True, "can_upload": True, - } + "max_storage_account": 100000000, + }, + "metrics": { + "account": { + "storage_used": 25000000, + }, + }, }, status=200, ) @@ -226,6 +279,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 +310,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 +325,432 @@ 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.POST, + 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.POST, + 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, +) +@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.POST, + 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_exceeded(): + """The quota gauge should be locked when the organization quota is reached.""" + responses.add( + responses.POST, + 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": "exceeded_locked", + "reason": "organization_quota_exceeded", + } + assert response.json()["can_upload"] == { + "result": False, + "reason": "organization_quota_exceeded", + } + + +@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.POST, + 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.POST, + 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_exceeded"), + ("user_override", "user_override_quota_exceeded"), + ("organization", "organization_quota_exceeded"), + ], +) +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.POST, + 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.POST, + 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", + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_usage_metrics_organization_aggregation(): + """The organization entry should aggregate active users sharing the same siret.""" + responses.add( + responses.POST, + ENTITLEMENTS_URL, + json={"entitlements": {"can_access": True, "can_upload": True}}, + status=200, + ) + + user = factories.UserFactory(claims={"siret": "21140001500015"}) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=700) + colleague = factories.UserFactory(claims={"siret": "21140001500015"}) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=colleague, size=800) + stranger = factories.UserFactory(claims={"siret": "99999999999999"}) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=stranger, size=1000) + former_colleague = factories.UserFactory(claims={"siret": "21140001500015"}, is_active=False) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=former_colleague, size=1000) + + client = APIClient() + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert json.loads(responses.calls[0].request.body) == { + "usage_metrics": [ + { + "account": {"type": "user", "id": user.sub, "email": user.email}, + "siret": "21140001500015", + "metrics": {"storage_used": 700}, + }, + { + "account": {"type": "organization"}, + "siret": "21140001500015", + "metrics": {"storage_used": 1500}, + }, + ], + } + + +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS=ENTITLEMENTS_BACKEND_PARAMETERS, +) +@responses.activate +def test_api_entitlements_deploycenter_usage_metrics_no_organization_claim(): + """Without a siret claim, only the user entry should be sent, without a siret.""" + responses.add( + responses.POST, + ENTITLEMENTS_URL, + json={"entitlements": {"can_access": True, "can_upload": True}}, + status=200, + ) + + client = APIClient() + user = factories.UserFactory(claims={}) + client.force_authenticate(user) + response = client.get("/api/v1.0/entitlements/") + assert response.status_code == 200 + assert "siret" not in responses.calls[0].request.url + assert json.loads(responses.calls[0].request.body) == { + "usage_metrics": [ + { + "account": {"type": "user", "id": user.sub, "email": user.email}, + "metrics": {"storage_used": 0}, + }, + ], + } + + +def test_api_entitlements_deploycenter_usage_metrics_custom_organization_claim(): + """A custom organization claim should drive both the lookup and the payload key.""" + backend = DeployCenterEntitlementsBackend( + base_url=ENTITLEMENTS_URL, + service_id=8, + api_key="secret", + organization_claim="org_id", + ) + user = factories.UserFactory(claims={"org_id": "acme", "siret": "21140001500015"}) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=user, size=700) + colleague = factories.UserFactory(claims={"org_id": "acme"}) + factories.ItemFactory(type=models.ItemTypeChoices.FILE, creator=colleague, size=800) + + assert backend.build_usage_metrics(user) == [ + { + "account": {"type": "user", "id": user.sub, "email": user.email}, + "org_id": "acme", + "metrics": {"storage_used": 700}, + }, + { + "account": {"type": "organization"}, + "org_id": "acme", + "metrics": {"storage_used": 1500}, + }, + ] + + def test_api_entitlements_deploycenter_missing_base_url_parameter(): """Missing base_url parameter should raise an exception.""" with pytest.raises(TypeError): 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..379d0f8b3 --- /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_exceeded", + "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_exceeded", + "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_exceeded", + "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_exceeded", + "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 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( 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/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..17329a109 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_exceeded", + USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_exceeded", + ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_exceeded", } type EntitlementOperator = { @@ -102,6 +105,13 @@ export type Entitlements = { operator?: EntitlementOperator; potentialOperators?: EntitlementOperator[]; }; + quota?: { + state: "default" | "exceeded_locked" | "error"; + reason?: string; + error?: string; + usage?: number; + limit?: number; + }; }; export abstract class Driver { 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/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/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..3f1c7a7d0 100644 --- a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx @@ -1,6 +1,13 @@ 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 { useRefreshEntitlementsQueryCache } from "../hooks/useRefreshItems"; import { getMyFilesQueryKey, getRecentItemsQueryKey, @@ -14,10 +21,12 @@ export const useMoveItems = () => { oldParentId?: string; }; + const { t } = useTranslation(); const queryClient = useQueryClient(); const driver = getDriver(); const removeItems = useRemoveItemsFromPaginatedList(); + const refreshEntitlements = useRefreshEntitlementsQueryCache(); return useMutation({ mutationFn: async (payload: MoveItemPayload) => { @@ -41,6 +50,7 @@ export const useMoveItems = () => { queryClient.invalidateQueries({ queryKey: ["items", payload.parentId], }); + refreshEntitlements(); }, onError: (err, variables) => { // If the mutation fails, you could invalidate to ensure fresh data @@ -51,6 +61,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/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 && ( { 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..94a0e2bc8 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts @@ -12,6 +12,7 @@ import { useDeleteMutationCallbacks, useRefreshItemCache, useRefreshFavoriteCache, + useRefreshEntitlementsQueryCache, } from "./useRefreshItems"; import { DefaultRoute } from "@/utils/defaultRoutes"; @@ -222,6 +223,7 @@ export const useMutationDuplicateItem = () => { const { item } = useGlobalExplorer(); const refresh = useRefreshQueryCacheAfterMutation(); + const refreshEntitlements = useRefreshEntitlementsQueryCache(); return useMutation({ mutationFn: (itemId: string) => { @@ -230,6 +232,11 @@ export const useMutationDuplicateItem = () => { onSuccess: () => { const parentId = item?.originalId ?? item?.id; refresh(parentId); + refreshEntitlements(); + }, + meta: { + // The caller already toasts a localized message on failure. + noGlobalError: true, }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts index 1b87f5ce5..e7d705318 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts @@ -14,14 +14,19 @@ export const useGetQueryKeyToRefresh = () => { 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 69b114c46..41d95ac0f 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx @@ -16,8 +16,11 @@ 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 { useRefreshQueryCacheAfterMutation } from "./useRefreshItems"; +import { errorToCode } from "@/features/api/APIError"; +import { + useRefreshEntitlementsQueryCache, + useRefreshQueryCacheAfterMutation, +} from "./useRefreshItems"; import { isIdInItemTree } from "../utils/utils"; type ActiveUpload = { @@ -33,6 +36,7 @@ import { customGetFilesFromEvent, isEmptyFolderMarker, } from "@/features/explorer/utils/dropTraversal"; +import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer"; type FileUpload = FileWithPath & { parentId?: string; @@ -239,6 +243,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { const driver = getDriver(); const refresh = useRefreshQueryCacheAfterMutation(); + const refreshEntitlements = useRefreshEntitlementsQueryCache(); const canCreateChildren = useCanCreateChildren(item); @@ -421,10 +426,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")} , @@ -544,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) { @@ -595,6 +603,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { } activeUploadsRef.current.delete(filePath); refresh(file.parentId); + hasUploadedFiles = true; setUploadingState((prev) => ({ ...prev, filesMeta: { @@ -618,10 +627,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) => ({ @@ -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, @@ -705,9 +717,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/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/__tests__/translations.test.ts b/src/frontend/apps/drive/src/features/i18n/__tests__/translations.test.ts new file mode 100644 index 000000000..2696b78fa --- /dev/null +++ b/src/frontend/apps/drive/src/features/i18n/__tests__/translations.test.ts @@ -0,0 +1,56 @@ +import translations from "../translations.json"; + +type TranslationNode = { [key: string]: string | TranslationNode }; + +const flattenKeys = (node: TranslationNode, prefix = ""): string[] => { + return Object.entries(node).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === "string" ? [path] : flattenKeys(value, path); + }); +}; + +const flattenEntries = ( + node: TranslationNode, + prefix = "" +): [string, string][] => { + return Object.entries(node).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === "string" + ? ([[path, value]] as [string, string][]) + : flattenEntries(value, path); + }); +}; + +const languages = Object.keys(translations) as (keyof typeof translations)[]; + +describe("translations.json", () => { + it("declares the same keys in every language", () => { + const keysByLanguage = Object.fromEntries( + languages.map((language) => [ + language, + flattenKeys(translations[language].translation).sort(), + ]) + ); + + for (const language of languages) { + // Compared pairwise against every other language so the failure + // diff shows exactly which keys are missing on which side. + for (const other of languages) { + const missing = keysByLanguage[language].filter( + (key) => !keysByLanguage[other].includes(key) + ); + expect({ language: other, missing }).toEqual({ + language: other, + missing: [], + }); + } + } + }); + + it.each(languages)("has no empty values in %s", (language) => { + const empty = flattenEntries(translations[language].translation) + .filter(([, value]) => value.trim() === "") + .map(([key]) => key); + expect(empty).toEqual([]); + }); +}); diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index f0dd74bc7..4d4a7de46 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_exceeded": { + "description": "You can no longer add documents, your personal quota has been reached. Contact your administrator." + }, + "user_override_quota_exceeded": { + "description": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator." + }, + "organization_quota_exceeded": { + "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." }, @@ -30,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_exceeded": { + "description": "Org. quota exceeded" + } + } + }, + "error": { + "title": "Quota error", + "label": "Contact support to resolve the error", + "tooltip": "Error: {{error}}" + } + } + }, "share_modal": { "options": { "link_reach": { @@ -157,7 +192,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_exceeded": "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.", + "user_override_quota_exceeded": "You can no longer move documents to the root, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_exceeded": "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": { @@ -334,6 +376,9 @@ "settings_workspace": "Workspace settings", "settings_folder": "Folder settings", "delete_item": "Delete", + "delete_workspace": "Delete workspace", + "delete_folder": "Delete folder", + "delete_file": "Delete file", "share": "Share", "share_view": "View shares" }, @@ -484,10 +529,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_exceeded": "You can no longer add documents, your personal quota has been reached. Contact your administrator.", + "user_override_quota_exceeded": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_exceeded": "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_exceeded": "Quota exceeded", + "user_override_quota_exceeded": "Quota exceeded", + "organization_quota_exceeded": "Quota exceeded", + "no_organization": "Upload not allowed", + "not_activated": "Upload not allowed" } }, "cancel_modal": { @@ -508,9 +563,15 @@ "toast_one": "Item deleted forever", "toast_other": "{{count}} items deleted forever" }, + "restore": { + "toast_one": "Item restored", + "toast_other": "{{count}} items restored" + }, "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": { @@ -587,6 +648,7 @@ "explorer": { "cancel": "Cancel", "choose": "Choose", + "picker_caption": "Select one or more files", "picker_label_zero": "No files selected", "picker_label_one": "{{count}} file selected", "picker_label_other": "{{count}} files selected", @@ -656,6 +718,15 @@ "close": "OK", "cannot_upload": { "title": "Les téléversements ne sont pas disponibles", + "user_quota_exceeded": { + "description": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur." + }, + "user_override_quota_exceeded": { + "description": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur." + }, + "organization_quota_exceeded": { + "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." }, @@ -677,6 +748,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_exceeded": { + "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": { @@ -804,7 +901,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_exceeded": "Vous ne pouvez plus déplacer de documents à la racine, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_exceeded": "Vous ne pouvez plus déplacer de documents à la racine, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_exceeded": "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 +1238,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_exceeded": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_exceeded": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_exceeded": "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_exceeded": "Quota atteint", + "user_override_quota_exceeded": "Quota atteint", + "organization_quota_exceeded": "Quota atteint", + "no_organization": "Téléversement non autorisé", + "not_activated": "Téléversement non autorisé" } }, "cancel_modal": { @@ -1164,7 +1278,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 +1427,15 @@ "close": "OK", "cannot_upload": { "title": "Uploaden is niet beschikbaar", + "user_quota_exceeded": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder." + }, + "user_override_quota_exceeded": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder." + }, + "organization_quota_exceeded": { + "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." }, @@ -1332,6 +1457,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_exceeded": { + "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": { @@ -1459,7 +1610,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_exceeded": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_exceeded": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_exceeded": "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": { @@ -1548,6 +1706,15 @@ } }, "filters": { + "location": { + "label": "Locatie", + "options": { + "my_files": "Mijn bestanden", + "shared_with_me": "Gedeeld met mij", + "starred": "Favorieten", + "trashbin": "Prullenbak" + } + }, "scopes": { "label": "Locatie", "options": { @@ -1564,6 +1731,39 @@ "reset": "Reset" } }, + "category": { + "label": "Type", + "options": { + "doc": "Tekstdocument", + "powerpoint": "Presentatie", + "calc": "Spreadsheet", + "pdf": "PDF", + "image": "Afbeelding", + "video": "Video", + "audio": "Audio", + "archive": "Archief", + "other": "Overig" + } + }, + "contact": { + "label": "Gedeeld met", + "placeholder": "Zoek een naam", + "empty": "Geen contacten gevonden", + "reset": "Opnieuw instellen" + }, + "modified": { + "label": "Gewijzigd", + "options": { + "today": "Vandaag", + "last_7_days": "Laatste 7 dagen", + "last_30_days": "Laatste 30 dagen", + "this_year": "Dit jaar", + "more_than_a_year": "Meer dan een jaar geleden", + "custom": "Aangepaste periode" + }, + "start": "Begin", + "end": "Einde" + }, "workspace": { "label": "Werkruimte" }, @@ -1594,6 +1794,9 @@ "settings_workspace": "Werkruimte instellingen", "settings_folder": "Map instellingen", "delete_item": "Verwijder", + "delete_workspace": "Ruimte verwijderen", + "delete_folder": "Map verwijderen", + "delete_file": "Bestand verwijderen", "share": "Deel", "share_view": "Bekijk delen" }, @@ -1744,10 +1947,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_exceeded": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_exceeded": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_exceeded": "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_exceeded": "Quotum bereikt", + "user_override_quota_exceeded": "Quotum bereikt", + "organization_quota_exceeded": "Quotum bereikt", + "no_organization": "Uploaden niet toegestaan", + "not_activated": "Uploaden niet toegestaan" } }, "cancel_modal": { @@ -1768,9 +1981,15 @@ "toast_one": "Item permanent verwijderd", "toast_other": "{{count}} items permanent verwijderd" }, + "restore": { + "toast_one": "Item hersteld", + "toast_other": "{{count}} items hersteld" + }, "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": { @@ -1828,7 +2047,6 @@ "copy_link": "Kopieer link", "languages": "Talen" }, - "time": { "years_ago_one": "{{count}} jaar geleden", "years_ago_other": "{{count}} jaren geleden", @@ -1848,6 +2066,7 @@ "explorer": { "choose": "Kiezen", "cancel": "Annuleren", + "picker_caption": "Selecteer een of meerdere bestanden", "picker_label_zero": "Geen bestanden geselecteerd", "picker_label_one": "{{count}} bestand geselecteerd", "picker_label_other": "{{count}} bestanden geselecteerd", 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..875f24615 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,17 @@ +@use "sass:map"; +@use "@/styles/cunningham-tokens-sass" as *; + +$tablet: map.get($themes, "default", "globals", "breakpoints", "tablet"); + .c__left-panel__footer__drive { padding: 12px; display: flex; + gap: 4px; + align-items: center; + + @media (max-width: $tablet) { + justify-content: space-between; + } } // Toast component from cunningham-react is not used in the drive app, so we hide it. @@ -8,3 +19,28 @@ .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; +} + +.user-profile { + display: flex; + align-items: center; + gap: 4px; +} \ No newline at end of file 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..ad4b07b41 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, @@ -10,14 +17,32 @@ import { } from "@/features/explorer/components/GlobalExplorerContext"; import { ExplorerRightPanelContent } from "@/features/explorer/components/right-panel/ExplorerRightPanelContent"; import { GlobalLayout } from "../global/GlobalLayout"; -import { LeftPanelMobile } from "../left-panel/LeftPanelMobile"; 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 +122,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 ( } rightPanelIsOpen={rightPanelOpen} onToggleRightPanel={() => setRightPanelOpen(!rightPanelOpen)} - leftPanelContent={user ? : } - leftPanelFooter={ - hasHelpMenu ? ( -
- window.open(helpMenuConfig.supportEmail) - : undefined - } - /> -
- ) : undefined - } + leftPanelContent={user ? : undefined} + leftPanelFooter={} isLeftPanelOpen={isLeftPanelOpen} hideLeftPanelOnDesktop={!user || isMinimalLayout} setIsLeftPanelOpen={() => setIsLeftPanelOpen(!isLeftPanelOpen)} @@ -137,3 +143,202 @@ export const ExplorerPanelsLayout = ({
); }; + +export const LeftPanelFooter = () => { + const { isTablet } = useResponsive(); + const settingsModal = useModal(); + + return ( + <> + {isTablet ? ( + + ) : ( + + )} + + + + ); +}; + +type LeftPanelFooterProps = { + openSettingsModal: () => void; +}; + +export const LeftPanelFooterMobile = (props: LeftPanelFooterProps) => { + const { user } = useAuth(); + if (!user) { + return ( +
+ + +
+ ); + } + 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; + } + console.log("storageGauge", storageGauge); + 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 === "exceeded_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: ( + +