mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Dan's review on #1546: - Stream the workbook to a /tmp temp file and S3Client.upload_file it, instead of render_workbook -> bytes -> put_object. render_workbook now saves straight to a path; the orchestrator renders to a temp file, multipart-uploads it, and always cleans it up. Restores ADR-0065's "never an in-memory BytesIO" decision (the OOM path at the 100k-row cap). - Move the raw `SELECT ... FROM scenario` out of the Lambda handler into ScenarioNamesPostgresRepository, so the handler stays composition-only and all SQL lives in repositories/. - current_sap_points goes through _float, matching its Optional[float] read-model type and the sibling numeric facts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
6.5 KiB
Python
180 lines
6.5 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__)
|
|
|
|
|
|
class ExportRowReader(Protocol):
|
|
def rows_for(
|
|
self,
|
|
*,
|
|
portfolio_id: int,
|
|
scenario_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 run(
|
|
self,
|
|
*,
|
|
portfolio_id: int,
|
|
scenario_ids: Sequence[int],
|
|
property_ids: Sequence[int],
|
|
recipient_email: str,
|
|
export_name: str,
|
|
) -> ScenarioExportResult:
|
|
named_sheets: list[tuple[str, ExportSheet]] = []
|
|
row_count = 0
|
|
for scenario_id in scenario_ids:
|
|
rows = self._rows.rows_for(
|
|
portfolio_id=portfolio_id,
|
|
scenario_id=scenario_id,
|
|
property_ids=property_ids,
|
|
)
|
|
sheet = shape_scenario_sheet(rows)
|
|
named_sheets.append((self._scenarios.name_for(scenario_id), sheet))
|
|
row_count += len(sheet.rows)
|
|
|
|
if row_count == 0:
|
|
# No Property had a default Plan under any requested Scenario (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,
|
|
"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
|