diff --git a/CHANGELOG.md b/CHANGELOG.md index 081229012..9a09ccb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to ### Added - ✨(backend) manage reconciliation requests for user accounts +- ✨(backend) add recursive folder export as ZIP archive +- ✨(frontend) add folder export action ### Changed diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d9383680..2a1d98974 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -18,6 +18,7 @@ from django.db import models as db from django.db.models.expressions import RawSQL from django.db.models.functions import Coalesce +from django.http import StreamingHttpResponse from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.functional import cached_property @@ -46,6 +47,7 @@ from core import enums, models from core.entitlements import get_entitlements_backend +from core.services.item_exports import build_zip_stream, export_descendants from core.services.sdk_relay import SDKRelayManager from core.services.search_indexers import ( get_file_indexer, @@ -77,8 +79,6 @@ f"{settings.MEDIA_URL:s}(?Ppreview/)?" f"(?P{ITEM_FOLDER:s}/(?P{UUID_REGEX:s})/.*{FILE_EXT_REGEX:s})$" ) - - # pylint: disable=too-many-ancestors @@ -1530,6 +1530,23 @@ def download(self, request, *args, **kwargs): headers={"Location": redirect_url}, ) + @drf.decorators.action(detail=True, methods=["get"], url_path="export") + def export(self, request, *args, **kwargs): + """ + Stream a recursive ZIP archive of a folder's content. + """ + folder = self.get_object() + + descendants = export_descendants(folder) + zip_stream = build_zip_stream(descendants) + + encoded_name = quote(f"{folder.title}.zip", safe="") + return StreamingHttpResponse( + zip_stream, + content_type="application/zip", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}"}, + ) + @drf.decorators.action(detail=False, methods=["get"], url_path="media-auth") def media_auth(self, request, *args, **kwargs): """ diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2d9b55d17..d3f1575f4 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1287,6 +1287,7 @@ def get_abilities(self, user): and self.type == ItemTypeChoices.FILE and self.upload_state == ItemUploadStateChoices.READY ) + can_export = can_get and self.type == ItemTypeChoices.FOLDER return { "accesses_manage": can_manage, @@ -1297,6 +1298,7 @@ def get_abilities(self, user): "destroy": can_destroy, "download": can_get, "duplicate": can_duplicate, + "export": can_export, "hard_delete": can_hard_delete, "favorite": can_get and user.is_authenticated, "link_configuration": can_manage, diff --git a/src/backend/core/services/item_exports.py b/src/backend/core/services/item_exports.py new file mode 100644 index 000000000..df47d1b1d --- /dev/null +++ b/src/backend/core/services/item_exports.py @@ -0,0 +1,62 @@ +"""Service for exporting item folders as streaming ZIP archives.""" + +from django.core.files.storage import default_storage + +from zipstream import ZipStream + +from core import models + +DEFAULT_STORAGE_READ_CHUNK_SIZE = 1024 + + +def iter_storage_chunks(file_key, chunk_size=DEFAULT_STORAGE_READ_CHUNK_SIZE): + """Yield bytes from object storage without buffering the whole file.""" + with default_storage.open(file_key, "rb") as fh: + while chunk := fh.read(chunk_size): + yield chunk + + +def export_descendants(folder): + """ + Yield (file_key_or_None, archive_path) tuples for a folder's subtree. + + Walks descendants ordered by path, skips descendants whose ancestors are + soft-deleted, computes the relative archive path for each item, and emits + a directory entry (`file_key=None`, trailing slash) for folders or a file + entry for `FILE` items in the `READY` upload state. + """ + descendants = folder.descendants().filter(ancestors_deleted_at__isnull=True).order_by("path") + + relative_paths = {str(folder.path): ""} + for descendant in descendants: + parent_key = str(descendant.path).rsplit(".", 1)[0] + parent_relative = relative_paths.get(parent_key) + if parent_relative is None: + continue + + name = ( + descendant.filename + if descendant.type == models.ItemTypeChoices.FILE + else descendant.title + ) + relative = f"{parent_relative}/{name}" if parent_relative else name + relative_paths[str(descendant.path)] = relative + + if descendant.type == models.ItemTypeChoices.FOLDER: + yield None, f"{relative}/" + elif descendant.upload_state == models.ItemUploadStateChoices.READY: + yield descendant.file_key, relative + + +def build_zip_stream(descendants): + """Build a ZIP stream that lazily reads exported files from storage.""" + zip_stream = ZipStream(sized=False) + for file_key, archive_path in descendants: + if file_key is None: + zip_stream.mkdir(archive_path) + else: + zip_stream.add( + data=iter_storage_chunks(file_key), + arcname=archive_path, + ) + return zip_stream diff --git a/src/backend/core/tests/items/test_api_items_export.py b/src/backend/core/tests/items/test_api_items_export.py new file mode 100644 index 000000000..645507503 --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_export.py @@ -0,0 +1,278 @@ +""" +Tests for the recursive folder export endpoint. +""" + +import io +import zipfile + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def _zip_names(response): + """Return the filenames contained in a streamed zip response.""" + payload = b"".join(response.streaming_content) + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + return archive.namelist() + + +def _zip_content(response, name): + """Return the bytes stored at `name` in a streamed zip response.""" + payload = b"".join(response.streaming_content) + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + with archive.open(name) as fh: + return fh.read() + + +def test_api_items_export_anonymous_public(): + """Anonymous users can export a public folder.""" + folder = factories.ItemFactory( + link_reach="public", + type=models.ItemTypeChoices.FOLDER, + title="public-folder", + ) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"hello", + upload_bytes__filename="hello.txt", + ) + + response = APIClient().get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert response["Content-Type"] == "application/zip" + assert "public-folder.zip" in response["Content-Disposition"] + assert _zip_names(response) == ["hello.txt"] + + +@pytest.mark.parametrize("reach", ["authenticated", "restricted"]) +def test_api_items_export_anonymous_authenticated_or_restricted(reach): + """Anonymous users cannot export folders that are not public.""" + folder = factories.ItemFactory(link_reach=reach, type=models.ItemTypeChoices.FOLDER) + + response = APIClient().get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 401 + + +def test_api_items_export_authenticated_restricted(): + """Authenticated users without access cannot export a restricted folder.""" + folder = factories.ItemFactory(link_reach="restricted", type=models.ItemTypeChoices.FOLDER) + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 403 + + +@pytest.mark.parametrize("via", VIA) +def test_api_items_export_related(via, mock_user_teams): + """Users with direct or team access can export a folder.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER, title="my-folder") + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"# readme", + upload_bytes__filename="readme.md", + ) + + if via == USER: + factories.UserItemAccessFactory(item=folder, user=user) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamItemAccessFactory(item=folder, team="lasuite") + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_content(response, "readme.md") == b"# readme" + + +def test_api_items_export_item_not_a_folder(): + """Files cannot be exported through the folder export endpoint.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + users=[(user, models.RoleChoices.OWNER)], + ) + + response = client.get(f"/api/v1.0/items/{item.pk}/export/") + + assert response.status_code == 403 + + +def test_api_items_export_preserves_hierarchy(): + """Files keep their relative paths in the exported zip.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + root = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + title="root", + users=[(user, models.RoleChoices.OWNER)], + ) + sub = factories.ItemFactory(parent=root, type=models.ItemTypeChoices.FOLDER, title="sub") + factories.ItemFactory( + parent=root, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"top", + upload_bytes__filename="top.txt", + ) + factories.ItemFactory( + parent=sub, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"nested", + upload_bytes__filename="nested.txt", + ) + + response = client.get(f"/api/v1.0/items/{root.pk}/export/") + + assert response.status_code == 200 + assert sorted(_zip_names(response)) == ["sub/", "sub/nested.txt", "top.txt"] + + +def test_api_items_export_skips_soft_deleted_descendants(): + """Soft-deleted descendants must not appear in the exported zip.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + keep = factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"keep", + upload_bytes__filename="keep.txt", + ) + drop = factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"drop", + upload_bytes__filename="drop.txt", + ) + drop.soft_delete() + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_names(response) == [keep.filename] + + +@pytest.mark.parametrize( + "upload_state", + [ + state + for state in models.ItemUploadStateChoices.values + if state != models.ItemUploadStateChoices.READY + ], +) +def test_api_items_export_skips_non_uploaded_files(upload_state): + """Files that are not ready are excluded from the exported zip.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"ready", + upload_bytes__filename="ready.txt", + ) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + filename="busy.txt", + upload_state=upload_state, + ) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_names(response) == ["ready.txt"] + + +def test_api_items_export_empty_folder(): + """Exporting an empty folder returns an empty zip archive.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_names(response) == [] + + +def test_api_items_export_includes_empty_subfolders(): + """Subfolders must appear as directory entries in the zip.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + root = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + title="root", + users=[(user, models.RoleChoices.OWNER)], + ) + sub = factories.ItemFactory(parent=root, type=models.ItemTypeChoices.FOLDER, title="sub") + factories.ItemFactory(parent=sub, type=models.ItemTypeChoices.FOLDER, title="nested") + + response = client.get(f"/api/v1.0/items/{root.pk}/export/") + + assert response.status_code == 200 + assert sorted(_zip_names(response)) == ["sub/", "sub/nested/"] + + +def test_api_items_export_filename_with_unicode(): + """The Content-Disposition header must encode unicode folder names safely.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + title="été 2026", + users=[(user, models.RoleChoices.OWNER)], + ) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + disposition = response["Content-Disposition"] + assert disposition == "attachment; filename*=UTF-8''%C3%A9t%C3%A9%202026.zip" diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index 678e736ff..c3757e3c0 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -240,6 +240,7 @@ def test_models_items_get_abilities_forbidden( "children_list": False, "destroy": False, "duplicate": False, + "export": False, "hard_delete": False, "favorite": False, "invite_owner": False, @@ -279,6 +280,7 @@ def test_models_items_get_abilities_reader(is_authenticated, reach, django_asser """ item = factories.ItemFactory(link_reach=reach, link_role="reader") user = factories.UserFactory() if is_authenticated else AnonymousUser() + can_export = item.type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": False, @@ -287,6 +289,7 @@ def test_models_items_get_abilities_reader(is_authenticated, reach, django_asser "children_list": True, "destroy": False, "duplicate": False, + "export": can_export, "hard_delete": False, "favorite": is_authenticated, "invite_owner": False, @@ -380,6 +383,7 @@ def test_models_items_get_abilities_editor( # noqa: PLR0913 ) user = factories.UserFactory() if is_authenticated else AnonymousUser() + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": False, @@ -388,6 +392,7 @@ def test_models_items_get_abilities_editor( # noqa: PLR0913 "children_list": True, "destroy": False, "duplicate": can_duplicate, + "export": can_export, "hard_delete": False, "favorite": is_authenticated, "invite_owner": False, @@ -440,6 +445,7 @@ def test_models_items_not_root_get_abilities_owner( item = factories.ItemFactory( users=[(user, "owner")], type=item_type, update_upload_state=upload_state ) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": True, "accesses_view": True, @@ -448,6 +454,7 @@ def test_models_items_not_root_get_abilities_owner( "children_list": True, "destroy": True, "duplicate": can_duplicate, + "export": can_export, "hard_delete": True, "favorite": True, "invite_owner": True, @@ -480,6 +487,7 @@ def test_models_items_not_root_get_abilities_owner( "children_list": False, "destroy": False, "duplicate": False, + "export": False, "hard_delete": True, "favorite": False, "invite_owner": False, @@ -524,6 +532,7 @@ def test_models_items_not_root_get_abilities_administrator( type=item_type, update_upload_state=upload_state, ) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": True, "accesses_view": True, @@ -532,6 +541,7 @@ def test_models_items_not_root_get_abilities_administrator( "children_list": True, "destroy": False, "duplicate": can_duplicate, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, @@ -594,6 +604,7 @@ def test_models_items_not_root_get_abilities_editor_user( update_upload_state=upload_state, ) link_select_options = LinkReachChoices.get_select_options(**item.ancestors_link_definition) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": True, @@ -602,6 +613,7 @@ def test_models_items_not_root_get_abilities_editor_user( "children_list": True, "destroy": False, "duplicate": can_duplicate, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, @@ -640,6 +652,7 @@ def test_models_items_not_root_get_abilities_reader_user(django_assert_num_queri ) item = factories.ItemFactory(parent=parent) access_from_link = item.link_reach != "restricted" and item.link_role == "editor" + can_export = item.type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": True, @@ -648,6 +661,7 @@ def test_models_items_not_root_get_abilities_reader_user(django_assert_num_queri "children_list": True, "destroy": False, "duplicate": access_from_link, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, @@ -709,6 +723,7 @@ def test_models_items_get_abilities_hard_delete_non_root_by_non_creator( "destroy": True, "download": True, "duplicate": False, + "export": False, "hard_delete": True, "favorite": True, "invite_owner": True, @@ -738,6 +753,7 @@ def test_models_items_get_abilities_hard_delete_non_root_by_non_creator( "destroy": False, "download": False, "duplicate": False, + "export": False, "hard_delete": True, "favorite": False, "invite_owner": False, diff --git a/src/backend/core/tests/test_models_items_root.py b/src/backend/core/tests/test_models_items_root.py index 7b2067fa4..d79bb596c 100644 --- a/src/backend/core/tests/test_models_items_root.py +++ b/src/backend/core/tests/test_models_items_root.py @@ -51,6 +51,7 @@ def test_models_sub_item_abilities_downgraded(): "children_list": True, "destroy": True, "duplicate": False, + "export": False, "hard_delete": True, "favorite": True, "invite_owner": False, @@ -85,6 +86,7 @@ def test_models_sub_item_abilities_downgraded(): "children_list": True, "destroy": True, "duplicate": False, + "export": False, "hard_delete": True, "favorite": True, "invite_owner": False, @@ -132,6 +134,7 @@ def test_models_items_root_get_abilities_owner( users=[(user, "owner")], type=item_type, update_upload_state=upload_state ) link_select_options = LinkReachChoices.get_select_options(**item.ancestors_link_definition) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": True, "accesses_view": True, @@ -140,6 +143,7 @@ def test_models_items_root_get_abilities_owner( "children_list": True, "destroy": True, "duplicate": can_duplicate, + "export": can_export, "hard_delete": True, "favorite": True, "invite_owner": True, @@ -168,6 +172,7 @@ def test_models_items_root_get_abilities_owner( "children_list": False, "destroy": False, "duplicate": False, + "export": False, "hard_delete": True, "favorite": False, "invite_owner": False, @@ -213,6 +218,7 @@ def test_models_items_root_get_abilities_administrator( update_upload_state=upload_state, ) link_select_options = LinkReachChoices.get_select_options(**item.ancestors_link_definition) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": True, "accesses_view": True, @@ -221,6 +227,7 @@ def test_models_items_root_get_abilities_administrator( "children_list": True, "destroy": False, "duplicate": can_duplicate, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, @@ -273,6 +280,7 @@ def test_models_items_root_get_abilities_editor_user( users=[(user, "editor")], type=item_type, update_upload_state=upload_state ) link_select_options = LinkReachChoices.get_select_options(**item.ancestors_link_definition) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": True, @@ -281,6 +289,7 @@ def test_models_items_root_get_abilities_editor_user( "children_list": True, "destroy": False, "duplicate": can_duplicate, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, @@ -323,6 +332,7 @@ def test_models_items_root_get_abilities_reader_user( item = factories.ItemFactory(users=[(user, "reader")], type=item_type) access_from_link = item.link_reach != "restricted" and item.link_role == "editor" link_select_options = LinkReachChoices.get_select_options(**item.ancestors_link_definition) + can_export = item_type == models.ItemTypeChoices.FOLDER expected_abilities = { "accesses_manage": False, "accesses_view": True, @@ -331,6 +341,7 @@ def test_models_items_root_get_abilities_reader_user( "children_list": True, "destroy": False, "duplicate": can_duplicate and access_from_link, + "export": can_export, "hard_delete": False, "favorite": True, "invite_owner": False, diff --git a/src/backend/core/tests/test_services_item_exports.py b/src/backend/core/tests/test_services_item_exports.py new file mode 100644 index 000000000..5e2df9a73 --- /dev/null +++ b/src/backend/core/tests/test_services_item_exports.py @@ -0,0 +1,149 @@ +"""Tests for the item_exports service.""" + +import uuid +from io import BytesIO + +from django.core.files.storage import default_storage + +import pytest + +from core import factories, models +from core.services.item_exports import export_descendants, iter_storage_chunks + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="stored_blob") +def fixture_stored_blob(): + """Save a blob in object storage and yield its key, cleaning up after.""" + key = f"test/iter_storage_chunks-{uuid.uuid4()}.bin" + payload = b"abcdefghij" * 10 + default_storage.save(key, BytesIO(payload)) + try: + yield key, payload + finally: + default_storage.delete(key) + + +def test_services_item_exports_iter_storage_chunks_returns_full_content(stored_blob): + """Concatenated chunks rebuild the original payload.""" + key, payload = stored_blob + + assert b"".join(iter_storage_chunks(key)) == payload + + +def test_services_item_exports_iter_storage_chunks_respects_chunk_size(stored_blob): + """A small chunk_size yields several chunks bounded by that size.""" + key, payload = stored_blob + + chunks = list(iter_storage_chunks(key, chunk_size=8)) + + assert len(chunks) > 1 + assert all(len(chunk) <= 8 for chunk in chunks) + assert b"".join(chunks) == payload + + +def test_services_item_exports_iter_storage_chunks_empty_file(): + """An empty stored file yields no chunks.""" + key = f"test/iter_storage_chunks-empty-{uuid.uuid4()}.bin" + default_storage.save(key, BytesIO(b"")) + + try: + chunks = list(iter_storage_chunks(key)) + assert not chunks + finally: + default_storage.delete(key) + + +def test_services_item_exports_export_descendants_yields_file_key_and_relative_archive_path(): + """A single ready file is emitted as one (file_key, archive_path) tuple.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + file_item = factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + filename="hello.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + + descendants = list(export_descendants(folder)) + + assert descendants == [(file_item.file_key, "hello.txt")] + + +def test_services_item_exports_export_descendants_builds_hierarchical_archive_path(): + """Files nested in subfolders carry their folder path in archive_path.""" + root = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + sub = factories.ItemFactory(parent=root, type=models.ItemTypeChoices.FOLDER, title="sub") + top_file = factories.ItemFactory( + parent=root, + type=models.ItemTypeChoices.FILE, + filename="top.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + nested_file = factories.ItemFactory( + parent=sub, + type=models.ItemTypeChoices.FILE, + filename="nested.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + + by_archive_path = { + archive_path: file_key for file_key, archive_path in export_descendants(root) + } + + assert by_archive_path == { + "top.txt": top_file.file_key, + "sub/": None, + "sub/nested.txt": nested_file.file_key, + } + + +@pytest.mark.parametrize( + "upload_state", + [ + models.ItemUploadStateChoices.PENDING, + models.ItemUploadStateChoices.DUPLICATING, + ], +) +def test_services_item_exports_export_descendants_skips_non_ready_files(upload_state): + """Only files in the READY upload state are yielded.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + filename="busy.txt", + upload_state=upload_state, + ) + ready = factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + filename="ready.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + + descendants = list(export_descendants(folder)) + + assert [file_key for file_key, _ in descendants] == [ready.file_key] + + +def test_services_item_exports_export_descendants_skips_soft_deleted_descendants(): + """Descendants under a soft-deleted ancestor are excluded.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + filename="keep.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + deleted_sub = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FOLDER) + factories.ItemFactory( + parent=deleted_sub, + type=models.ItemTypeChoices.FILE, + filename="drop.txt", + update_upload_state=models.ItemUploadStateChoices.READY, + ) + deleted_sub.soft_delete() + + descendants = list(export_descendants(folder)) + + assert [archive_path for _, archive_path in descendants] == ["keep.txt"] diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index ba8804d99..4d0c6b7e1 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "sentry-sdk==2.58.0", "url-normalize==2.2.1", "whitenoise==6.12.0", + "zipstream-ng==1.9.0", ] [project.urls] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 530483bd4..c32b9f049 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -700,6 +700,7 @@ dependencies = [ { name = "sentry-sdk" }, { name = "url-normalize" }, { name = "whitenoise" }, + { name = "zipstream-ng" }, ] [package.optional-dependencies] @@ -781,6 +782,7 @@ requires-dist = [ { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.33.0.20260408" }, { name = "url-normalize", specifier = "==2.2.1" }, { name = "whitenoise", specifier = "==6.12.0" }, + { name = "zipstream-ng", specifier = "==1.9.0" }, ] provides-extras = ["dev"] @@ -1806,3 +1808,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd07 wheels = [ { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, ] + +[[package]] +name = "zipstream-ng" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963, upload-time = "2025-08-29T01:03:36.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852, upload-time = "2025-08-29T01:03:35.046Z" }, +] diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index 5acc9d2f8..7a6b20ac2 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -79,6 +79,7 @@ export type Item = { children_create: boolean; children_list: boolean; destroy: boolean; + export: boolean; favorite: boolean; invite_owner: boolean; link_configuration: boolean; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx index f9ba0becc..39be937d1 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx @@ -20,6 +20,7 @@ import { useGlobalExplorer, } from "../components/GlobalExplorerContext"; import { useDownloadItem } from "@/features/items/hooks/useDownloadItem"; +import { baseApiUrl } from "@/features/api/utils"; import { ExplorerRenameItemModal } from "../components/modals/ExplorerRenameItemModal"; import { ExplorerCreateFolderModal } from "../components/modals/ExplorerCreateFolderModal"; import { ItemShareModal } from "../components/modals/share/ItemShareModal"; @@ -164,6 +165,14 @@ export const useItemActionMenuItems = ({ handleDownloadItem(item); }, }, + { + icon: , + label: t("explorer.item.actions.export"), + isHidden: !item.abilities?.export || minimal, + callback: () => { + window.location.href = `${baseApiUrl()}items/${effectiveItemId}/export/`; + }, + }, { icon: , label: t("explorer.item.actions.duplicate"), diff --git a/src/frontend/apps/e2e/__tests__/app-drive/context-menu.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/context-menu.spec.ts index 884cf227b..ea474d566 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/context-menu.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/context-menu.spec.ts @@ -79,11 +79,44 @@ test.describe("Context menu", () => { ).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Share" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Move" })).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Export" }), + ).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Star" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); }); + test("Right-click on folder > Export calls the export endpoint", async ({ + page, + }) => { + await createFolderInCurrentFolder(page, "TestFolder"); + + await page.route("**/api/v1.0/items/*/export/", async (route) => { + await route.fulfill({ + status: 200, + headers: { + "content-disposition": "attachment; filename=test.zip", + "content-type": "application/zip", + }, + body: "", + }); + }); + + const exportRequestPromise = page.waitForRequest((request) => + /\/api\/v1\.0\/items\/[^/]+\/export\/$/.test(request.url()), + ); + + const row = await getRowItem(page, "TestFolder"); + await row.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Export" }).click(); + + const exportRequest = await exportRequestPromise; + expect(exportRequest.url()).toMatch( + /\/api\/v1\.0\/items\/[^/]+\/export\/$/, + ); + }); + test("Right-click on item > Rename works", async ({ page }) => { await createFolderInCurrentFolder(page, "TestFolder");