mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
122 lines
4.1 KiB
Python
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),
|
|
)
|