Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 19 additions & 2 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -77,8 +79,6 @@
f"{settings.MEDIA_URL:s}(?P<preview>preview/)?"
f"(?P<key>{ITEM_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/.*{FILE_EXT_REGEX:s})$"
)


# pylint: disable=too-many-ancestors


Expand Down Expand Up @@ -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):
"""
Expand Down
2 changes: 2 additions & 0 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions src/backend/core/services/item_exports.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
lunika marked this conversation as resolved.
"""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):
Comment thread
lunika marked this conversation as resolved.
"""
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
Loading
Loading