Model/applications/ara_export/handler.py
Khalim Conn-Kowlessar 8bd9c564dc Add the scenario export route, worker handler and SQS trigger 🟩
Wires POST /v1/exports/scenario -> tasks.inputs recipe -> pinned sub_task ->
ARA_EXPORT_SQS_URL -> ara_export Lambda -> orchestrator. Route resolution and the
trigger body are covered by tests; the Lambda handler mirrors bulk_document_download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:36:14 +00:00

121 lines
4.5 KiB
Python

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