"""ara_export Lambda (ADR-0065). Attach-mode (ADR-0055) handler. The FastAPI route created the app-owned Task + SubTask and pinned the recipe onto the SubTask's ``inputs``; this reads that recipe, builds the sheet-per-scenario branded workbook through the orchestrator, and returns the result (presigned URL + counts), which ``TaskOrchestrator`` records on ``sub_task.outputs``. A selection that yields no rows raises ``SubTaskFailure`` from the orchestrator — recorded, not retried. PostgresConfig-only (POSTGRES_*), like the bulk_document_download Lambda. The workbook is written to the dedicated ``DOCUMENT_EXPORTS_BUCKET``; SES SMTP settings arrive as env vars. """ from __future__ import annotations import logging import os from typing import Any import boto3 from sqlalchemy import text from sqlmodel import Session from applications.ara_export.ara_export_trigger_body import AraExportTriggerBody from domain.tasks.tasks import Source from infrastructure.email.ses_smtp_email_sender import SesSmtpEmailSender from infrastructure.postgres.config import PostgresConfig from infrastructure.postgres.engine import make_engine, make_session from infrastructure.s3.s3_client import S3Client from orchestration.scenario_export_orchestrator import ScenarioExportOrchestrator from repositories.scenario_export.scenario_export_postgres_repository import ( ScenarioExportPostgresRepository, ) from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository from utilities.aws_lambda.task_handler import task_handler logging.getLogger().setLevel(logging.INFO) logger = logging.getLogger(__name__) # The export link is valid for 60 minutes (ADR-0065). _URL_TTL_SECONDS = 3600 class _ScenarioNames: """Scenario id → display name, read from the ``scenario`` table (the domain Scenario aggregate does not carry the name).""" def __init__(self, session: Session, scenario_ids: list[int]) -> None: rows = ( session.connection() .execute( text("SELECT id, name FROM scenario WHERE id = ANY(:ids)"), {"ids": scenario_ids}, ) .all() ) self._names: dict[int, str] = { row[0]: (row[1] or f"scenario_{row[0]}") for row in rows } def name_for(self, scenario_id: int) -> str: return self._names.get(scenario_id, f"scenario_{scenario_id}") def _email_sender() -> SesSmtpEmailSender: return SesSmtpEmailSender( host=os.environ["SES_SMTP_HOST"], port=int(os.environ["SES_SMTP_PORT"]), username=os.environ["SES_SMTP_USERNAME"], password=os.environ["SES_SMTP_PASSWORD"], from_address=os.environ["SES_SMTP_FROM_ADDRESS"], ) @task_handler(task_source="ara_export", source=Source.PORTFOLIO) def handler(body: dict[str, Any], context: Any) -> dict[str, Any]: trigger = AraExportTriggerBody.model_validate(body) engine = make_engine(PostgresConfig.from_env(os.environ)) session = make_session(engine) try: subtask = SubTaskPostgresRepository(session).get(trigger.subtask_id) recipe: dict[str, Any] = subtask.inputs or {} portfolio_id = int(recipe["portfolio_id"]) scenario_ids: list[int] = [int(x) for x in recipe["scenario_ids"]] property_ids: list[int] = [int(x) for x in recipe["property_ids"]] recipient_email = str(recipe["recipient_email"]) export_name = str(recipe["export_name"]) logger.info( "ara_export: starting export for %d properties across %d scenario(s) " "(subtask=%s)", len(property_ids), len(scenario_ids), trigger.subtask_id, ) boto_s3: Any = boto3.client("s3") # pyright: ignore[reportUnknownMemberType] orchestrator = ScenarioExportOrchestrator( rows=ScenarioExportPostgresRepository(session), scenarios=_ScenarioNames(session, scenario_ids), exports=S3Client(boto_s3, os.environ["DOCUMENT_EXPORTS_BUCKET"]), email=_email_sender(), url_ttl_seconds=_URL_TTL_SECONDS, ) result = orchestrator.run( portfolio_id=portfolio_id, scenario_ids=scenario_ids, property_ids=property_ids, recipient_email=recipient_email, export_name=export_name, ) finally: session.close() return { "presigned_url": result.presigned_url, "export_s3_key": result.export_s3_key, "sheet_count": result.sheet_count, "row_count": result.row_count, }