mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Build and upload the sheet-per-scenario export workbook 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e313f19ef2
commit
ace3150363
2 changed files with 176 additions and 0 deletions
81
orchestration/scenario_export_orchestrator.py
Normal file
81
orchestration/scenario_export_orchestrator.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""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
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from domain.scenario_export.scenario_sheet import (
|
||||
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:
|
||||
raise NotImplementedError
|
||||
95
tests/orchestration/test_scenario_export_orchestrator.py
Normal file
95
tests/orchestration/test_scenario_export_orchestrator.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""The Scenario Export orchestrator (ADR-0065): read → shape → render → upload →
|
||||
presign → email, best-effort, verified with in-memory fakes and a moto S3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Iterator, Optional, Sequence
|
||||
|
||||
import pytest
|
||||
from moto import mock_aws
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from domain.scenario_export.scenario_sheet import ExportMeasure, PropertyScenarioData
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.scenario_export_orchestrator import ScenarioExportOrchestrator
|
||||
from tests.infrastructure import make_boto_client
|
||||
|
||||
EXPORTS_BUCKET = "domna-exports-dev"
|
||||
|
||||
|
||||
class _FakeRows:
|
||||
"""Returns preset export rows per scenario id."""
|
||||
|
||||
def __init__(self, by_scenario: dict[int, list[PropertyScenarioData]]) -> None:
|
||||
self._by_scenario = by_scenario
|
||||
|
||||
def rows_for(
|
||||
self,
|
||||
*,
|
||||
portfolio_id: int,
|
||||
scenario_id: int,
|
||||
property_ids: Sequence[int],
|
||||
) -> list[PropertyScenarioData]:
|
||||
return self._by_scenario.get(scenario_id, [])
|
||||
|
||||
|
||||
class _FakeScenarioNames:
|
||||
def __init__(self, names: dict[int, str]) -> None:
|
||||
self._names = names
|
||||
|
||||
def name_for(self, scenario_id: int) -> str:
|
||||
return self._names[scenario_id]
|
||||
|
||||
|
||||
class _RecordingEmail:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str, Optional[str]]] = []
|
||||
|
||||
def send(
|
||||
self, *, to: str, subject: str, body: str, html_body: Optional[str] = None
|
||||
) -> None:
|
||||
self.sent.append((to, subject, body, html_body))
|
||||
|
||||
|
||||
def _row(property_id: int) -> PropertyScenarioData:
|
||||
return PropertyScenarioData(
|
||||
property_id=property_id,
|
||||
landlord_property_id=f"LP{property_id}",
|
||||
measures=[ExportMeasure(measure_type="loft_insulation", estimated_cost=1200.0)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exports() -> Iterator[S3Client]:
|
||||
with mock_aws():
|
||||
boto_client = make_boto_client("s3")
|
||||
boto_client.create_bucket(Bucket=EXPORTS_BUCKET)
|
||||
yield S3Client(boto_client, EXPORTS_BUCKET)
|
||||
|
||||
|
||||
def test_builds_and_uploads_a_sheet_per_scenario_workbook(exports: S3Client) -> None:
|
||||
# arrange — two scenarios, each with a modelled property.
|
||||
orchestrator = ScenarioExportOrchestrator(
|
||||
rows=_FakeRows({5: [_row(1)], 9: [_row(1), _row(2)]}),
|
||||
scenarios=_FakeScenarioNames({5: "Fabric first", 9: "Low carbon"}),
|
||||
exports=exports,
|
||||
email=_RecordingEmail(),
|
||||
url_ttl_seconds=3600,
|
||||
)
|
||||
|
||||
# act
|
||||
result = orchestrator.run(
|
||||
portfolio_id=100,
|
||||
scenario_ids=[5, 9],
|
||||
property_ids=[1, 2],
|
||||
recipient_email="user@example.com",
|
||||
export_name="portfolio-100",
|
||||
)
|
||||
|
||||
# assert — the uploaded object is a real workbook with one sheet per scenario
|
||||
# and the presigned URL points at it.
|
||||
assert result.sheet_count == 2
|
||||
assert result.export_s3_key in result.presigned_url
|
||||
workbook = load_workbook(BytesIO(exports.get_object(result.export_s3_key)))
|
||||
assert workbook.sheetnames == ["Fabric first", "Low carbon"]
|
||||
Loading…
Add table
Reference in a new issue