Model/orchestration/bulk_document_download_orchestrator.py
Khalim Conn-Kowlessar e7181b7d1f Build, upload and email a Download Package for a property set 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:31:49 +00:00

122 lines
4.1 KiB
Python

"""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),
)