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 00000000..da815757 --- /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 2c8df87b..d91bfce4 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1034,6 +1034,13 @@ class Item(TreeModel, BaseModel): indexes = [ GistIndex(fields=["path"]), models.Index(NLevel(models.F("path")), name="drive_item_path_nlevel_idx"), + # Covers the storage used computation by creator. + models.Index( + fields=["creator"], + include=["size"], + condition=models.Q(hard_deleted_at__isnull=True), + name="item_creator_size_not_hdel_idx", + ), ] def __str__(self): diff --git a/src/backend/core/storage/creator_storage_compute_backend.py b/src/backend/core/storage/creator_storage_compute_backend.py index a21864c2..c2fb7082 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 @@ class CreatorStorageComputeBackend(StorageComputeBackend): """ Compute the total storage used by a set of users. """ - return Item.objects.filter(creator__in=users).aggregate(total_size=Sum("size", default=0))[ - "total_size" - ] + return Item.objects.filter(creator__in=users, hard_deleted_at__isnull=True).aggregate( + total_size=Sum("size", default=0) + )["total_size"] diff --git a/src/backend/core/tests/storage/test_creator_storage_compute_backend.py b/src/backend/core/tests/storage/test_creator_storage_compute_backend.py new file mode 100644 index 00000000..21d9b6f0 --- /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