"""The Bulk Document Download orchestrator (ADR-0060). Ties the pieces together for one run: gather a property set's uploaded files (by `landlord_property_id`), enrich each with its display address, lay them out with the Download Package plan, stream the chosen files into a single ZIP, upload it, mint a presigned URL, and email the requester. Best-effort — skipped rows are reported, not fatal; a wholly-empty selection is a recorded failure (ADR-0060). """ from __future__ import annotations import io import zipfile from dataclasses import dataclass from datetime import datetime from typing import Optional, Protocol, Sequence, cast from domain.bulk_document_download.package_plan import ( ResolvedDocument, SkippedDocument, plan_download_package, ) from infrastructure.postgres.uploaded_file_table import UploadedFile from infrastructure.s3.s3_client import S3Client from repositories.email.email_sender import EmailSender class UploadedFileReader(Protocol): def by_landlord_property_ids( self, landlord_property_ids: Sequence[str] ) -> list[UploadedFile]: ... class AddressResolver(Protocol): def address_for(self, landlord_property_id: str) -> str: ... class DocumentBytesReader(Protocol): def read(self, bucket: str, key: str) -> bytes: ... @dataclass(frozen=True) class DownloadPackageResult: """What a completed run reports (lands in `sub_task.outputs`, ADR-0060).""" presigned_url: str package_s3_key: str included: int skipped: tuple[SkippedDocument, ...] class BulkDocumentDownloadOrchestrator: def __init__( self, *, uploaded_files: UploadedFileReader, addresses: AddressResolver, documents: DocumentBytesReader, packages: S3Client, email: EmailSender, url_ttl_seconds: int, package_key_prefix: str = "bulk-downloads", ) -> None: self._uploaded_files = uploaded_files self._addresses = addresses self._documents = documents self._packages = packages self._email = email self._url_ttl_seconds = url_ttl_seconds self._package_key_prefix = package_key_prefix def run( self, *, landlord_property_ids: Sequence[str], recipient_email: str, package_name: str, ) -> DownloadPackageResult: files = self._uploaded_files.by_landlord_property_ids(landlord_property_ids) resolved = [self._resolve(file) for file in files] plan = plan_download_package([r for r in resolved if r is not None]) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for entry in plan.entries: archive.writestr( entry.zip_path, self._documents.read(entry.s3_bucket, entry.s3_key) ) key = f"{self._package_key_prefix}/{package_name}.zip" self._packages.put_object(key, buffer.getvalue()) url = self._packages.generate_presigned_url(key, self._url_ttl_seconds) self._email.send( to=recipient_email, subject="Your document download is ready", body=( f"Your document download is ready. It contains " f"{len(plan.entries)} document(s).\n\n" f"Download it here (link valid for " f"{self._url_ttl_seconds // 60} minutes):\n{url}" ), ) return DownloadPackageResult( presigned_url=url, package_s3_key=key, included=len(plan.entries), skipped=plan.skipped, ) def _resolve(self, file: UploadedFile) -> Optional[ResolvedDocument]: if file.landlord_property_id is None: return None return ResolvedDocument( landlord_property_id=file.landlord_property_id, address=self._addresses.address_for(file.landlord_property_id), document_type=file.file_type, s3_bucket=file.s3_file_bucket, s3_key=file.s3_file_key, uploaded_at=cast(datetime, file.s3_upload_timestamp), )