Model/orchestration/scenario_export_orchestrator.py
Khalim Conn-Kowlessar 72f3e3a72d Add default-plan export selection + plan_name column (ADR-0065)
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>
2026-07-22 09:21:38 +00:00

221 lines
8.1 KiB
Python

"""The Scenario Export orchestrator (ADR-0065).
Ties one export together: for each requested Scenario, read its Properties'
persisted rows, shape them into a sheet, render the sheet-per-scenario branded
workbook, upload it to the exports bucket, mint a presigned URL, and email the
requester. Best-effort email (the link is also recorded on ``sub_task.outputs``);
a selection that yields no rows for any Scenario is a recorded failure — never an
empty workbook emailed to the user.
"""
from __future__ import annotations
import html
import logging
import os
import tempfile
from dataclasses import dataclass
from typing import Protocol, Sequence
from domain.scenario_export.scenario_sheet import (
ExportSheet,
PropertyScenarioData,
shape_scenario_sheet,
)
from domain.tasks.subtasks import SubTaskFailure
from infrastructure.s3.s3_client import S3Client
from infrastructure.xlsx.scenario_export_workbook import render_workbook
from repositories.email.email_sender import EmailSender
logger = logging.getLogger(__name__)
# The single sheet title for the default-plan selection (ADR-0065).
_DEFAULT_PLAN_SHEET_NAME = "Default Plans"
class ExportRowReader(Protocol):
def rows_for(
self,
*,
portfolio_id: int,
scenario_id: int,
property_ids: Sequence[int],
) -> list[PropertyScenarioData]: ...
def default_rows_for(
self,
*,
portfolio_id: int,
property_ids: Sequence[int],
) -> list[PropertyScenarioData]: ...
class ScenarioNames(Protocol):
def name_for(self, scenario_id: int) -> str: ...
@dataclass(frozen=True)
class ScenarioExportResult:
"""What a completed export reports (lands on ``sub_task.outputs``)."""
presigned_url: str
export_s3_key: str
sheet_count: int
row_count: int
class ScenarioExportOrchestrator:
def __init__(
self,
*,
rows: ExportRowReader,
scenarios: ScenarioNames,
exports: S3Client,
email: EmailSender,
url_ttl_seconds: int,
key_prefix: str = "scenario-exports",
) -> None:
self._rows = rows
self._scenarios = scenarios
self._exports = exports
self._email = email
self._url_ttl_seconds = url_ttl_seconds
self._key_prefix = key_prefix
def _scenario_sheets(
self,
portfolio_id: int,
scenario_ids: Sequence[int],
property_ids: Sequence[int],
) -> list[tuple[str, ExportSheet]]:
named_sheets: list[tuple[str, ExportSheet]] = []
for scenario_id in scenario_ids:
rows = self._rows.rows_for(
portfolio_id=portfolio_id,
scenario_id=scenario_id,
property_ids=property_ids,
)
named_sheets.append(
(self._scenarios.name_for(scenario_id), shape_scenario_sheet(rows))
)
return named_sheets
def _default_plan_sheets(
self, portfolio_id: int, property_ids: Sequence[int]
) -> list[tuple[str, ExportSheet]]:
# One sheet: each home's default Plan (`is_default`), across Scenarios.
rows = self._rows.default_rows_for(
portfolio_id=portfolio_id, property_ids=property_ids
)
return [(_DEFAULT_PLAN_SHEET_NAME, shape_scenario_sheet(rows))]
def run(
self,
*,
portfolio_id: int,
scenario_ids: Sequence[int],
property_ids: Sequence[int],
recipient_email: str,
export_name: str,
plan_selection: str = "scenario",
) -> ScenarioExportResult:
# ADR-0065 two selections:
# - "scenario": one sheet per requested Scenario, the freshest Plan per
# (property, scenario).
# - "default": ONE sheet of each home's default Plan (`is_default`),
# one-per-property across all Scenarios — the portfolio's current
# state. `scenario_ids` is ignored.
if plan_selection == "default":
named_sheets = self._default_plan_sheets(portfolio_id, property_ids)
else:
named_sheets = self._scenario_sheets(
portfolio_id, scenario_ids, property_ids
)
row_count = sum(len(sheet.rows) for _, sheet in named_sheets)
if row_count == 0:
# No Property had a Plan under the selection (model A) — a recorded
# failure, never an empty workbook emailed (ADR-0065).
raise SubTaskFailure(
"the scenario export selection produced no exportable rows",
details={
"portfolio_id": portfolio_id,
"plan_selection": plan_selection,
"scenario_ids": list(scenario_ids),
"property_count": len(property_ids),
},
)
key = f"{self._key_prefix}/{export_name}.xlsx"
# Render to a /tmp file and multipart-upload it, so the workbook is never
# also held in memory as a bytes copy (ADR-0065 — the OOM path at the
# 100k-row cap). The temp file is always cleaned up.
handle, workbook_path = tempfile.mkstemp(suffix=".xlsx")
os.close(handle)
try:
render_workbook(named_sheets, workbook_path)
self._exports.upload_file(workbook_path, key)
finally:
try:
os.remove(workbook_path)
except OSError:
pass
url = self._exports.generate_presigned_url(key, self._url_ttl_seconds)
subject, body, html_body = self._compose_email(
url=url, sheets=len(named_sheets), rows=row_count
)
try:
self._email.send(
to=recipient_email, subject=subject, body=body, html_body=html_body
)
logger.info("scenario_export: emailed %s", recipient_email)
except Exception:
# Best-effort delivery (ADR-0065): the link is also written to
# sub_task.outputs, so an email/transport failure must not lose an
# export that was already built and uploaded.
logger.warning(
"scenario_export: email delivery to %s failed; the link is still "
"recorded on sub_task.outputs",
recipient_email,
exc_info=True,
)
return ScenarioExportResult(
presigned_url=url,
export_s3_key=key,
sheet_count=len(named_sheets),
row_count=row_count,
)
def _compose_email(
self, *, url: str, sheets: int, rows: int
) -> tuple[str, str, str]:
"""The delivery email (ADR-0059/0065): a subject, a plain-text body
carrying the raw link, and an HTML alternative with a clean download
button so the long presigned URL isn't the visible content."""
minutes = self._url_ttl_seconds // 60
sheet_noun = "scenario" if sheets == 1 else "scenarios"
subject = f"Your scenario export is ready ({sheets} {sheet_noun})"
body = (
"Your scenario export is ready.\n\n"
f"It covers {sheets} {sheet_noun} across {rows} property row(s).\n\n"
f"Download it here (link valid for {minutes} minutes):\n{url}\n"
)
safe_url = html.escape(url, quote=True)
html_body = (
'<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;'
'color:#1a1a1a;line-height:1.5;">'
"<p>Your scenario export is ready.</p>"
f"<p>It covers <strong>{sheets}</strong> {sheet_noun} across "
f"<strong>{rows}</strong> property row(s).</p>"
'<p style="margin:24px 0;">'
f'<a href="{safe_url}" style="display:inline-block;padding:12px 22px;'
"background:#6C6CFF;color:#ffffff;text-decoration:none;border-radius:6px;"
'font-weight:bold;">Download export</a></p>'
f'<p style="color:#666;font-size:13px;">This link expires in {minutes} '
"minutes. If the button doesn't work, paste this URL into your browser:"
f'<br><span style="word-break:break-all;">{safe_url}</span></p>'
"</div>"
)
return subject, body, html_body