mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Audit generator populates XLSX, uploads to S3, and records UploadedFile row 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f08a75e103
commit
a1d09aa880
11 changed files with 427 additions and 0 deletions
|
|
@ -0,0 +1 @@
|
|||
from applications.audit_generator.handler import handler # noqa: F401
|
||||
|
|
@ -0,0 +1 @@
|
|||
from applications.audit_generator.audit_generator_trigger_request import AuditGeneratorTriggerRequest # noqa: F401
|
||||
0
applications/audit_generator/__init__.py
Normal file
0
applications/audit_generator/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AuditGeneratorTriggerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
task_id: str
|
||||
sub_task_id: str
|
||||
hubspot_deal_id: str
|
||||
40
applications/audit_generator/handler.py
Normal file
40
applications/audit_generator/handler.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
|
||||
from applications.audit_generator.audit_generator_trigger_request import (
|
||||
AuditGeneratorTriggerRequest,
|
||||
)
|
||||
from infrastructure.postgres.config import PostgresConfig
|
||||
from infrastructure.postgres.engine import make_engine, make_session
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator
|
||||
from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork
|
||||
from utilities.aws_lambda.subtask_handler import subtask_handler
|
||||
|
||||
_engine = make_engine(PostgresConfig.from_env(os.environ))
|
||||
|
||||
|
||||
@subtask_handler()
|
||||
def handler(body: dict[str, Any], context: Any) -> None:
|
||||
trigger = AuditGeneratorTriggerRequest.model_validate(body)
|
||||
|
||||
boto3_client: Any = boto3.client # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
boto_s3: Any = boto3_client("s3")
|
||||
bucket = os.environ["S3_BUCKET_NAME"]
|
||||
s3_client = S3Client(boto_s3_client=boto_s3, bucket=bucket)
|
||||
|
||||
def session_factory() -> Any:
|
||||
return make_session(_engine)
|
||||
|
||||
def uow_factory() -> AuditGeneratorUnitOfWork:
|
||||
return AuditGeneratorUnitOfWork(session_factory)
|
||||
|
||||
AuditGeneratorOrchestrator(
|
||||
hubspot_deal_id=trigger.hubspot_deal_id,
|
||||
s3_client=s3_client,
|
||||
uow_factory=uow_factory,
|
||||
).run()
|
||||
24
orchestration/audit_generator_orchestrator.py
Normal file
24
orchestration/audit_generator_orchestrator.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork
|
||||
|
||||
|
||||
class AuditGeneratorOrchestrator:
|
||||
def __init__(
|
||||
self,
|
||||
hubspot_deal_id: str,
|
||||
s3_client: S3Client,
|
||||
uow_factory: Callable[[], "AuditGeneratorUnitOfWork"],
|
||||
) -> None:
|
||||
self._hubspot_deal_id = hubspot_deal_id
|
||||
self._s3_client = s3_client
|
||||
self._uow_factory = uow_factory
|
||||
|
||||
def run(self) -> None:
|
||||
raise NotImplementedError
|
||||
39
orchestration/audit_generator_unit_of_work.py
Normal file
39
orchestration/audit_generator_unit_of_work.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import TracebackType
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from repositories.magic_plan.magic_plan_postgres_repository import (
|
||||
MagicPlanPostgresRepository,
|
||||
)
|
||||
from repositories.uploaded_file.uploaded_file_postgres_repository import (
|
||||
UploadedFilePostgresRepository,
|
||||
)
|
||||
|
||||
|
||||
class AuditGeneratorUnitOfWork:
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
def __enter__(self) -> "AuditGeneratorUnitOfWork":
|
||||
self._session = self._session_factory()
|
||||
self.uploaded_file = UploadedFilePostgresRepository(self._session)
|
||||
self.magic_plan = MagicPlanPostgresRepository(self._session)
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[type[BaseException]],
|
||||
exc: Optional[BaseException],
|
||||
tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
try:
|
||||
self._session.rollback()
|
||||
finally:
|
||||
self._session.close()
|
||||
|
||||
def commit(self) -> None:
|
||||
self._session.commit()
|
||||
0
tests/applications/audit_generator/__init__.py
Normal file
0
tests/applications/audit_generator/__init__.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from applications.audit_generator.handler import handler
|
||||
|
||||
_ENV = {
|
||||
"DATABASE_URL": "postgresql+psycopg://user:pass@localhost/db",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
# PostgresConfig.from_env also reads these individual vars; DATABASE_URL is
|
||||
# used directly when constructing the engine in the handler module scope, so
|
||||
# we patch make_engine instead of the env.
|
||||
}
|
||||
|
||||
_VALID_BODY: dict[str, Any] = {
|
||||
"task_id": "task-1",
|
||||
"sub_task_id": "subtask-1",
|
||||
"hubspot_deal_id": "deal-xyz",
|
||||
}
|
||||
|
||||
|
||||
def _call(body: dict[str, Any]) -> Any:
|
||||
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# --- request validation ---
|
||||
|
||||
|
||||
def test_invalid_body_raises_validation_error() -> None:
|
||||
# Arrange — body missing all required fields
|
||||
body: dict[str, Any] = {}
|
||||
|
||||
# Act / Assert
|
||||
with patch("applications.audit_generator.handler.AuditGeneratorOrchestrator"):
|
||||
with pytest.raises(ValidationError):
|
||||
_call(body)
|
||||
|
||||
|
||||
# --- orchestrator construction ---
|
||||
|
||||
|
||||
def test_handler_passes_hubspot_deal_id_from_body_to_orchestrator() -> None:
|
||||
# Arrange
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.run.return_value = None
|
||||
|
||||
# Act
|
||||
with patch("applications.audit_generator.handler.os.environ", _ENV), \
|
||||
patch("applications.audit_generator.handler.S3Client") as MockS3, \
|
||||
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch) as MockOrch:
|
||||
MockS3.return_value = MagicMock()
|
||||
_call(_VALID_BODY)
|
||||
|
||||
# Assert — deal id flows from body into the orchestrator constructor
|
||||
MockOrch.assert_called_once()
|
||||
assert MockOrch.call_args.kwargs["hubspot_deal_id"] == "deal-xyz"
|
||||
|
||||
|
||||
def test_handler_passes_bucket_from_env_to_s3_client() -> None:
|
||||
# Arrange
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.run.return_value = None
|
||||
|
||||
# Act
|
||||
with patch("applications.audit_generator.handler.os.environ", _ENV), \
|
||||
patch("applications.audit_generator.handler.S3Client") as MockS3, \
|
||||
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch):
|
||||
_call(_VALID_BODY)
|
||||
|
||||
# Assert — bucket name from env reaches S3Client constructor
|
||||
MockS3.assert_called_once()
|
||||
assert MockS3.call_args.kwargs["bucket"] == "test-bucket"
|
||||
|
||||
|
||||
# --- return value ---
|
||||
|
||||
|
||||
def test_handler_returns_none_on_success() -> None:
|
||||
# Arrange
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.run.return_value = None
|
||||
|
||||
# Act
|
||||
with patch("applications.audit_generator.handler.os.environ", _ENV), \
|
||||
patch("applications.audit_generator.handler.S3Client"), \
|
||||
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch):
|
||||
result = _call(_VALID_BODY)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
0
tests/orchestration/audit_generator/__init__.py
Normal file
0
tests/orchestration/audit_generator/__init__.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.magicplan.models import (
|
||||
Door,
|
||||
DoorVentilation,
|
||||
Floor,
|
||||
Plan,
|
||||
Room,
|
||||
Window,
|
||||
WindowVentilation,
|
||||
)
|
||||
from infrastructure.postgres.uploaded_file_table import (
|
||||
FileSourceEnum,
|
||||
FileTypeEnum,
|
||||
UploadedFile,
|
||||
)
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator
|
||||
|
||||
_DEAL_ID = "deal-abc"
|
||||
_BUCKET = "test-bucket"
|
||||
_EXPECTED_S3_KEY = f"documents/hubspot_deal_id/{_DEAL_ID}/ventilation_audit.xlsx"
|
||||
|
||||
|
||||
def _make_window(with_ventilation: bool = True) -> Window:
|
||||
vent = (
|
||||
WindowVentilation(
|
||||
opening_type="Hinged",
|
||||
num_openings=1,
|
||||
pct_openable=50,
|
||||
trickle_vent_area_mm2=1000,
|
||||
num_trickle_vents=2,
|
||||
)
|
||||
if with_ventilation
|
||||
else None
|
||||
)
|
||||
return Window(width_m=1.0, height_m=1.2, area_m2=1.2, ventilation=vent)
|
||||
|
||||
|
||||
def _make_door(with_ventilation: bool = True) -> Door:
|
||||
vent = DoorVentilation(undercut_mm=10.0) if with_ventilation else None
|
||||
return Door(width_mm=800.0, height_mm=2000.0, ventilation=vent)
|
||||
|
||||
|
||||
def _make_plan(
|
||||
num_rooms: int = 1,
|
||||
num_windows_per_room: int = 1,
|
||||
num_doors_per_room: int = 1,
|
||||
) -> Plan:
|
||||
rooms = [
|
||||
Room(
|
||||
name=f"Room {i}",
|
||||
width_m=3.0,
|
||||
length_m=4.0,
|
||||
area_m2=12.0,
|
||||
windows=[_make_window() for _ in range(num_windows_per_room)],
|
||||
doors=[_make_door() for _ in range(num_doors_per_room)],
|
||||
)
|
||||
for i in range(num_rooms)
|
||||
]
|
||||
return Plan(
|
||||
uid="test-uid",
|
||||
name="Test Plan",
|
||||
address="1 Test St",
|
||||
postcode="TE1 1ST",
|
||||
floors=[Floor(level=0, name="Ground", rooms=rooms)],
|
||||
)
|
||||
|
||||
|
||||
def _make_uploaded_file_row(id: int = 1) -> UploadedFile:
|
||||
return UploadedFile(
|
||||
id=id,
|
||||
s3_file_bucket=_BUCKET,
|
||||
s3_file_key="documents/deal/plan.json",
|
||||
s3_upload_timestamp=None, # type: ignore[arg-type]
|
||||
hubspot_deal_id=_DEAL_ID,
|
||||
file_type=FileTypeEnum.MAGIC_PLAN_JSON.value,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_uow(
|
||||
uploaded_file_row: Any = None,
|
||||
plan: Any = None,
|
||||
) -> tuple[MagicMock, MagicMock]:
|
||||
"""Return (mock_uow, mock_uow_factory)."""
|
||||
mock_uow = MagicMock()
|
||||
mock_uow.__enter__ = MagicMock(return_value=mock_uow)
|
||||
mock_uow.__exit__ = MagicMock(return_value=False)
|
||||
mock_uow.uploaded_file.get_latest_by_hubspot_deal_id.return_value = uploaded_file_row
|
||||
mock_uow.magic_plan.get_plan_by_uploaded_file_id.return_value = plan
|
||||
mock_uow_factory = MagicMock(return_value=mock_uow)
|
||||
return mock_uow, mock_uow_factory
|
||||
|
||||
|
||||
def _make_s3() -> MagicMock:
|
||||
s3 = MagicMock(spec=S3Client)
|
||||
s3.bucket = _BUCKET
|
||||
return s3
|
||||
|
||||
|
||||
def _make_orchestrator(
|
||||
s3: Any = None,
|
||||
uow_factory: Any = None,
|
||||
deal_id: str = _DEAL_ID,
|
||||
) -> AuditGeneratorOrchestrator:
|
||||
return AuditGeneratorOrchestrator(
|
||||
hubspot_deal_id=deal_id,
|
||||
s3_client=s3 or _make_s3(),
|
||||
uow_factory=uow_factory or MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
# --- error: no uploaded file ---
|
||||
|
||||
|
||||
def test_raises_when_no_magic_plan_json_uploaded() -> None:
|
||||
# Arrange
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(uploaded_file_row=None)
|
||||
orch = _make_orchestrator(uow_factory=mock_uow_factory)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="No MagicPlan"):
|
||||
orch.run()
|
||||
|
||||
|
||||
# --- error: plan not yet parsed ---
|
||||
|
||||
|
||||
def test_raises_when_plan_not_yet_parsed() -> None:
|
||||
# Arrange
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(
|
||||
uploaded_file_row=_make_uploaded_file_row(), plan=None
|
||||
)
|
||||
orch = _make_orchestrator(uow_factory=mock_uow_factory)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="not yet parsed"):
|
||||
orch.run()
|
||||
|
||||
|
||||
# --- happy path ---
|
||||
|
||||
|
||||
def test_uploads_to_correct_s3_key() -> None:
|
||||
# Arrange
|
||||
s3 = _make_s3()
|
||||
plan = _make_plan()
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(
|
||||
uploaded_file_row=_make_uploaded_file_row(), plan=plan
|
||||
)
|
||||
orch = _make_orchestrator(s3=s3, uow_factory=mock_uow_factory)
|
||||
|
||||
# Act
|
||||
orch.run()
|
||||
|
||||
# Assert
|
||||
s3.put_object.assert_called_once()
|
||||
assert s3.put_object.call_args.args[0] == _EXPECTED_S3_KEY
|
||||
|
||||
|
||||
def test_inserts_uploaded_file_with_correct_enums() -> None:
|
||||
# Arrange
|
||||
plan = _make_plan()
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(
|
||||
uploaded_file_row=_make_uploaded_file_row(), plan=plan
|
||||
)
|
||||
orch = _make_orchestrator(uow_factory=mock_uow_factory)
|
||||
|
||||
# Act
|
||||
orch.run()
|
||||
|
||||
# Assert — the UploadedFile inserted has the correct type/source
|
||||
mock_uow.uploaded_file.insert.assert_called_once()
|
||||
inserted: UploadedFile = mock_uow.uploaded_file.insert.call_args.args[0]
|
||||
assert inserted.file_type == FileTypeEnum.VENTILATION_AUDIT.value
|
||||
assert inserted.file_source == FileSourceEnum.AUDIT_GENERATOR.value
|
||||
assert inserted.hubspot_deal_id == _DEAL_ID
|
||||
assert inserted.s3_file_key == _EXPECTED_S3_KEY
|
||||
|
||||
|
||||
def test_commits_after_s3_upload() -> None:
|
||||
# Arrange
|
||||
s3 = _make_s3()
|
||||
plan = _make_plan()
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(
|
||||
uploaded_file_row=_make_uploaded_file_row(), plan=plan
|
||||
)
|
||||
call_order: list[str] = []
|
||||
s3.put_object.side_effect = lambda *a, **kw: call_order.append("s3_upload")
|
||||
mock_uow.commit.side_effect = lambda: call_order.append("commit")
|
||||
orch = _make_orchestrator(s3=s3, uow_factory=mock_uow_factory)
|
||||
|
||||
# Act
|
||||
orch.run()
|
||||
|
||||
# Assert — S3 upload happens before DB commit
|
||||
assert call_order == ["s3_upload", "commit"]
|
||||
|
||||
|
||||
# --- 50-row limit ---
|
||||
|
||||
|
||||
def test_raises_when_series_exceeds_50_rows() -> None:
|
||||
# Arrange — 51 windows, which exceeds the template limit
|
||||
plan = _make_plan(num_rooms=1, num_windows_per_room=51)
|
||||
mock_uow, mock_uow_factory = _make_mock_uow(
|
||||
uploaded_file_row=_make_uploaded_file_row(), plan=plan
|
||||
)
|
||||
orch = _make_orchestrator(uow_factory=mock_uow_factory)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="50"):
|
||||
orch.run()
|
||||
Loading…
Add table
Reference in a new issue