"""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 = ( '
' "

Your scenario export is ready.

" f"

It covers {sheets} {sheet_noun} across " f"{rows} property row(s).

" '

' f'Download export

' f'

This link expires in {minutes} ' "minutes. If the button doesn't work, paste this URL into your browser:" f'
{safe_url}

' "
" ) return subject, body, html_body