mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
A second export selection alongside the per-Scenario one, chosen by `plan_selection` on the export request: - "scenario" (default, unchanged): one sheet per Scenario, freshest Plan per (property, scenario); scenario_ids required. - "default" (new): a single "Default Plans" sheet of each home's is_default Plan (one-per-property across all Scenarios, ADR-0012/0017) — the portfolio's current state, one row per home; scenario_ids ignored. Repository gains `default_rows_for(portfolio_id, property_ids)`: same read-model and Effective-EPC join as `rows_for`, with the plan-selection WHERE swapped from `scenario_id = :scenario_id` to `is_default = TRUE`. Orchestrator branches on plan_selection; the router validates it and only requires scenario_ids for the scenario selection. A recipe without plan_selection defaults to "scenario", so the change is backward-compatible. Both selections now surface the chosen Plan's `name` as a `plan_name` column. Tests: repository default_rows_for (scenario-independent selection, plan_name, non-default excluded); orchestrator default path (one "Default Plans" sheet; empty selection is a recorded failure). 62 passed, pyright --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.2 KiB
Python
106 lines
4.2 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 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.scenario_export.scenario_names_postgres_repository import (
|
|
ScenarioNamesPostgresRepository,
|
|
)
|
|
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
|
|
|
|
|
|
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"])
|
|
# ADR-0065 plan selection: "scenario" (default) or "default" (each
|
|
# home's is_default Plan). Absent on pre-existing recipes → "scenario".
|
|
plan_selection = str(recipe.get("plan_selection") or "scenario")
|
|
|
|
logger.info(
|
|
"ara_export: starting %s export for %d properties across %d "
|
|
"scenario(s) (subtask=%s)",
|
|
plan_selection,
|
|
len(property_ids),
|
|
len(scenario_ids),
|
|
trigger.subtask_id,
|
|
)
|
|
|
|
boto_s3: Any = boto3.client("s3") # pyright: ignore[reportUnknownMemberType]
|
|
orchestrator = ScenarioExportOrchestrator(
|
|
rows=ScenarioExportPostgresRepository(session),
|
|
scenarios=ScenarioNamesPostgresRepository(session),
|
|
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,
|
|
plan_selection=plan_selection,
|
|
)
|
|
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,
|
|
}
|