This commit is contained in:
Daniel Roth 2026-06-08 09:57:34 +00:00 committed by GitHub
commit 1f8420434a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1495 additions and 411270 deletions

View file

@ -1,7 +1,7 @@
import re
from typing import Optional
from datatypes.magicplan.api.response import PlanSummary
from domain.magicplan.api.response import PlanSummary
_UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}", re.IGNORECASE)

View file

@ -0,0 +1,47 @@
import os
from typing import Any
import boto3
from infrastructure.magic_plan.config import MagicPlanConfig
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
from infrastructure.s3.s3_client import S3Client
from orchestration.magic_plan_orchestrator import MagicPlanOrchestrator
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from domain.magicplan.models import Plan
from utilities.aws_lambda.subtask_handler import subtask_handler
from utilities.logger import setup_logger
logger = setup_logger()
@subtask_handler()
def handler(body: dict[str, Any], context: Any) -> str:
config = MagicPlanConfig.from_env(os.environ)
payload = MagicPlanTriggerRequest.model_validate(body)
client = MagicPlanClient(
customer_id=config.customer_id,
api_key=config.api_key,
)
boto3_client: Any = boto3.client # type: ignore
boto_s3: Any = boto3_client("s3")
s3_client = S3Client(
boto_s3_client=boto_s3, bucket="retrofit-energy-assessments-dev"
)
# TODO: read s3_bucket from env var so staging/prod use the correct bucket
plan: Plan = MagicPlanOrchestrator(client, s3_client).run(payload)
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
return plan.uid
if __name__ == "__main__":
event = {
"Records": [
{
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ", "hubspot_deal_id": "local-test-deal"}',
}
]
}
handler(event, None)

View file

@ -0,0 +1,17 @@
FROM public.ecr.aws/lambda/python:3.11
WORKDIR /var/task
COPY applications/magic_plan/handler/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY utilities/ utilities/
COPY backend/ backend/
COPY applications/ applications/
COPY domain/ domain/
COPY datatypes/ datatypes/
COPY orchestration/ orchestration/
COPY repositories/ repositories/
COPY infrastructure/ infrastructure/
CMD ["applications.magic_plan.handler.handler"]

View file

@ -1,5 +1,5 @@
from datatypes.magicplan.api.response import PlanSummary
from backend.magic_plan.address_matcher import find_matching_plan, _extract_postcode
from domain.magicplan.api.response import PlanSummary
from applications.magic_plan.address_matcher import find_matching_plan, _extract_postcode
def _make_plan(

View file

@ -1,143 +0,0 @@
from typing import Any, cast
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlmodel import Session, col
from datatypes.magicplan.domain.models import Floor, Plan
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
def save_plan(session: Session, plan: Plan, uploaded_file_id: int) -> None:
plan_id: int = _upsert_plan(session, plan, uploaded_file_id)
_delete_children(session, plan_id)
floor_ids: list[int] = _insert_floors(session, plan.floors, plan_id)
room_ids: list[int] = _insert_rooms(session, plan.floors, floor_ids)
_insert_windows_and_doors(session, plan.floors, room_ids)
def _upsert_plan(session: Session, plan: Plan, uploaded_file_id: int) -> int:
stmt = (
pg_insert(MagicPlanPlanModel)
.values(
magic_plan_uid=plan.uid,
name=plan.name,
address=plan.address,
postcode=plan.postcode,
uploaded_file_id=uploaded_file_id,
)
.on_conflict_do_update(
index_elements=["magic_plan_uid"],
set_={
"name": plan.name,
"address": plan.address,
"postcode": plan.postcode,
"uploaded_file_id": uploaded_file_id,
},
)
.returning(col(MagicPlanPlanModel.id))
)
row_id: int = session.execute(stmt).scalar_one()
return row_id
def _delete_children(session: Session, plan_id: int) -> None:
floor_subq = (
select(col(MagicPlanFloorModel.id))
.where(col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id)
.scalar_subquery()
)
room_subq = (
select(col(MagicPlanRoomModel.id))
.where(col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq))
.scalar_subquery()
)
session.execute(
delete(MagicPlanWindowModel).where(
col(MagicPlanWindowModel.magic_plan_room_id).in_(room_subq)
)
)
session.execute(
delete(MagicPlanDoorModel).where(
col(MagicPlanDoorModel.magic_plan_room_id).in_(room_subq)
)
)
session.execute(
delete(MagicPlanRoomModel).where(
col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq)
)
)
session.execute(
delete(MagicPlanFloorModel).where(
col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id
)
)
def _insert_floors(session: Session, floors: list[Floor], plan_id: int) -> list[int]:
rows: list[dict[str, Any]] = [
{"magic_plan_plan_id": plan_id, "level": floor.level} for floor in floors
]
result = session.execute(
pg_insert(MagicPlanFloorModel)
.values(rows)
.returning(col(MagicPlanFloorModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_rooms(
session: Session, floors: list[Floor], floor_ids: list[int]
) -> list[int]:
rows: list[dict[str, Any]] = [
{
"magic_plan_floor_id": floor_id,
"name": room.name,
"width_m": room.width_m,
"length_m": room.length_m,
"area_m2": room.area_m2,
}
for floor, floor_id in zip(floors, floor_ids)
for room in floor.rooms
]
result = session.execute(
pg_insert(MagicPlanRoomModel).values(rows).returning(col(MagicPlanRoomModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_windows_and_doors(
session: Session, floors: list[Floor], room_ids: list[int]
) -> None:
all_rooms = [room for floor in floors for room in floor.rooms]
window_rows: list[dict[str, Any]] = [
{
"magic_plan_room_id": room_id,
"width_m": window.width_m,
"height_m": window.height_m,
"area_m2": window.area_m2,
"opening_type": window.opening_type,
}
for room, room_id in zip(all_rooms, room_ids)
for window in room.windows
]
door_rows: list[dict[str, Any]] = [
{
"magic_plan_room_id": room_id,
"width_mm": door.width_mm,
}
for room, room_id in zip(all_rooms, room_ids)
for door in room.doors
]
if window_rows:
session.execute(pg_insert(MagicPlanWindowModel).values(window_rows))
if door_rows:
session.execute(pg_insert(MagicPlanDoorModel).values(door_rows))

View file

@ -1,41 +0,0 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
import backend.app.db.models.magic_plan # noqa: F401 — registers MagicPlan models with SQLModel.metadata
# TODO: promote to backend/app/db/conftest.py once a second DB-touching test directory appears under this tree
@pytest.fixture(scope="function")
def engine(postgresql):
connection_string = (
f"postgresql+psycopg://"
f"{postgresql.info.user}:"
f"{postgresql.info.password}@"
f"{postgresql.info.host}:"
f"{postgresql.info.port}/"
f"{postgresql.info.dbname}"
)
engine = create_engine(connection_string)
SQLModel.metadata.create_all(engine)
yield engine
SQLModel.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture(scope="function")
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()

View file

@ -1,115 +0,0 @@
import json
from pathlib import Path
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlmodel import SQLModel
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from backend.app.db.functions.magic_plan_functions import save_plan
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
FIXTURE_DIR = Path(__file__).parents[4] / "magic_plan"
@pytest.fixture(scope="module")
def domain_plan() -> Plan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return map_plan(MagicPlanPlan.model_validate(data["data"]))
def _count(session: Session, model: type[SQLModel]) -> int:
return session.execute(select(func.count()).select_from(model)).scalar_one()
def test_plan_row_present_after_save(db_session: Session, domain_plan: Plan) -> None:
# Act
save_plan(db_session, domain_plan, 1)
# Assert
assert _count(db_session, MagicPlanPlanModel) == 1
def test_floor_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = len(domain_plan.floors)
# Act
save_plan(db_session, domain_plan, 1)
# Assert
assert _count(db_session, MagicPlanFloorModel) == expected
def test_room_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(f.rooms) for f in domain_plan.floors)
# Act
save_plan(db_session, domain_plan, 1)
# Assert
assert _count(db_session, MagicPlanRoomModel) == expected
def test_window_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(r.windows) for f in domain_plan.floors for r in f.rooms)
# Act
save_plan(db_session, domain_plan, 1)
# Assert
assert _count(db_session, MagicPlanWindowModel) == expected
def test_door_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(r.doors) for f in domain_plan.floors for r in f.rooms)
# Act
save_plan(db_session, domain_plan, 1)
# Assert
assert _count(db_session, MagicPlanDoorModel) == expected
def test_save_plan_idempotent(db_session: Session, domain_plan: Plan) -> None:
# Act — call twice within the same session
save_plan(db_session, domain_plan, 1)
save_plan(db_session, domain_plan, 1)
# Assert — same row counts as a single call
assert _count(db_session, MagicPlanPlanModel) == 1
assert _count(db_session, MagicPlanFloorModel) == len(domain_plan.floors)
assert _count(db_session, MagicPlanRoomModel) == sum(
len(f.rooms) for f in domain_plan.floors
)
assert _count(db_session, MagicPlanWindowModel) == sum(
len(r.windows) for f in domain_plan.floors for r in f.rooms
)
assert _count(db_session, MagicPlanDoorModel) == sum(
len(r.doors) for f in domain_plan.floors for r in f.rooms
)
def test_uploaded_file_id_stored_after_save(db_session: Session, domain_plan: Plan) -> None:
# Act
save_plan(db_session, domain_plan, 1)
# Assert
row = db_session.execute(select(MagicPlanPlanModel)).scalar_one()
assert row.uploaded_file_id == 1
def test_save_plan_updates_uploaded_file_id_on_reingest(
db_session: Session, domain_plan: Plan
) -> None:
# Arrange
save_plan(db_session, domain_plan, 1)
# Act
save_plan(db_session, domain_plan, 2)
# Assert
row = db_session.execute(select(MagicPlanPlanModel)).scalar_one()
assert row.uploaded_file_id == 2

View file

@ -1,53 +0,0 @@
from typing import Optional
from sqlmodel import Field, SQLModel
class MagicPlanPlanModel(SQLModel, table=True):
__tablename__ = "magic_plan_plan"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_uid: Optional[str] = Field(default=None, unique=True, index=True)
name: Optional[str] = None
address: Optional[str] = None
postcode: Optional[str] = None
uploaded_file_id: Optional[int] = Field(default=None)
class MagicPlanFloorModel(SQLModel, table=True):
__tablename__ = "magic_plan_floor"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_plan_id: int = Field(foreign_key="magic_plan_plan.id")
level: Optional[int] = None
class MagicPlanRoomModel(SQLModel, table=True):
__tablename__ = "magic_plan_room"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_floor_id: int = Field(foreign_key="magic_plan_floor.id")
name: Optional[str] = None
width_m: Optional[float] = None
length_m: Optional[float] = None
area_m2: Optional[float] = None
class MagicPlanWindowModel(SQLModel, table=True):
__tablename__ = "magic_plan_window"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_m: Optional[float] = None
height_m: Optional[float] = None
area_m2: Optional[float] = None
opening_type: Optional[str] = None
class MagicPlanDoorModel(SQLModel, table=True):
__tablename__ = "magic_plan_door"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_mm: Optional[float] = None
type: Optional[str] = None

View file

@ -1,4 +1,8 @@
from backend.app.db.models.magic_plan import (
from typing import Any, cast
import sqlalchemy as sa
from infrastructure.postgres.magic_plan_tables import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
@ -6,6 +10,10 @@ from backend.app.db.models.magic_plan import (
MagicPlanWindowModel,
)
def _table(model: type[Any]) -> sa.Table:
return cast(sa.Table, getattr(model, "__table__"))
# --- MagicPlanPlan ---
@ -14,20 +22,17 @@ def test_plan_table_name() -> None:
def test_plan_has_magic_plan_uid_column() -> None:
assert "magic_plan_uid" in MagicPlanPlanModel.__table__.columns
assert "magic_plan_uid" in _table(MagicPlanPlanModel).columns
def test_plan_magic_plan_uid_is_unique() -> None:
col = MagicPlanPlanModel.__table__.columns["magic_plan_uid"]
assert (
any(
c.unique
for c in MagicPlanPlanModel.__table__.constraints
if hasattr(c, "columns")
and "magic_plan_uid" in [cc.name for cc in c.columns]
)
or col.unique
t = _table(MagicPlanPlanModel)
col = t.columns["magic_plan_uid"]
has_unique_constraint = any(
isinstance(c, sa.UniqueConstraint) and "magic_plan_uid" in c.columns
for c in t.constraints
)
assert has_unique_constraint or col.unique
def test_plan_instantiation() -> None:
@ -47,7 +52,7 @@ def test_floor_table_name() -> None:
def test_floor_fk_column_name() -> None:
assert "magic_plan_plan_id" in MagicPlanFloorModel.__table__.columns
assert "magic_plan_plan_id" in _table(MagicPlanFloorModel).columns
def test_floor_has_level() -> None:
@ -63,11 +68,11 @@ def test_room_table_name() -> None:
def test_room_fk_column_name() -> None:
assert "magic_plan_floor_id" in MagicPlanRoomModel.__table__.columns
assert "magic_plan_floor_id" in _table(MagicPlanRoomModel).columns
def test_room_has_measurement_columns() -> None:
cols = MagicPlanRoomModel.__table__.columns
cols = _table(MagicPlanRoomModel).columns
assert "width_m" in cols
assert "length_m" in cols
assert "area_m2" in cols
@ -89,15 +94,14 @@ def test_window_table_name() -> None:
def test_window_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanWindowModel.__table__.columns
assert "magic_plan_room_id" in _table(MagicPlanWindowModel).columns
def test_window_has_measurement_columns() -> None:
cols = MagicPlanWindowModel.__table__.columns
cols = _table(MagicPlanWindowModel).columns
assert "width_m" in cols
assert "height_m" in cols
assert "area_m2" in cols
assert "opening_type" in cols
def test_window_instantiation() -> None:
@ -106,9 +110,8 @@ def test_window_instantiation() -> None:
width_m=1.4,
height_m=1.2,
area_m2=1.68,
opening_type="casement",
)
assert window.opening_type == "casement"
assert window.width_m == 1.4
# --- MagicPlanDoor ---
@ -119,16 +122,16 @@ def test_door_table_name() -> None:
def test_door_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanDoorModel.__table__.columns
assert "magic_plan_room_id" in _table(MagicPlanDoorModel).columns
def test_door_has_width_mm_and_type() -> None:
cols = MagicPlanDoorModel.__table__.columns
cols = _table(MagicPlanDoorModel).columns
assert "width_mm" in cols
assert "type" in cols
def test_door_instantiation() -> None:
door = MagicPlanDoorModel(magic_plan_room_id=1, width_mm=0.79, type="hinged")
assert door.width_mm == 0.79
door = MagicPlanDoorModel(magic_plan_room_id=1, width_mm=790.0, type="hinged")
assert door.width_mm == 790.0
assert door.type == "hinged"

View file

@ -1,39 +0,0 @@
from typing import Any
from backend.app.config import get_settings
from backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_service import MagicPlanService
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from datatypes.magicplan.domain.models import Plan
from backend.app.db.models.tasks import SourceEnum
from backend.utils.subtasks import task_handler
from utils.logger import setup_logger
logger = setup_logger()
@task_handler(task_source="magic_plan", source=SourceEnum.HUBSPOT_DEAL)
def handler(body: dict[str, Any], context: Any) -> str:
settings = get_settings()
payload = MagicPlanTriggerRequest.model_validate(body)
client = MagicPlanClient(
customer_id=settings.MAGICPLAN_CUSTOMER_ID,
api_key=settings.MAGICPLAN_API_KEY,
)
# TODO: read s3_bucket from env var so staging/prod use the correct bucket
plan: Plan = MagicPlanService(
client, s3_bucket="retrofit-energy-assessments-dev"
).run(payload)
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
return plan.uid
if __name__ == "__main__":
event = {
"Records": [
{
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ", "hubspot_deal_id": "local-test-deal"}',
}
]
}
handler(event, None)

View file

@ -1,12 +0,0 @@
FROM public.ecr.aws/lambda/python:3.11
WORKDIR /var/task
COPY backend/magic_plan/handler/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY utils/ utils/
COPY backend/ backend/
COPY datatypes/ datatypes/
CMD ["backend.magic_plan.handler.handler"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,109 +0,0 @@
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from backend.magic_plan.handler import handler
ADDRESS = "2 Laburnum Way Bromley BR2 8BZ"
PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
def _make_settings(**overrides: str) -> MagicMock:
settings = MagicMock()
settings.MAGICPLAN_CUSTOMER_ID = overrides.get("customer_id", "cust-123")
settings.MAGICPLAN_API_KEY = overrides.get("api_key", "key-abc")
return settings
def _call_handler(body: dict[str, Any]) -> Any:
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
@pytest.fixture()
def mock_plan() -> MagicMock:
plan = MagicMock()
plan.uid = PLAN_UID
return plan
@pytest.fixture()
def mock_service(mock_plan: MagicMock) -> MagicMock:
service = MagicMock()
service.run.return_value = mock_plan
return service
# --- request validation ---
def test_handler_raises_on_missing_address(mock_plan: MagicMock) -> None:
# Arrange
body: dict[str, Any] = {}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService"):
# Act / Assert
with pytest.raises(ValidationError):
_call_handler(body)
# --- client construction ---
def test_handler_constructs_client_from_settings(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings(customer_id="cust-xyz", api_key="key-xyz")), \
patch("backend.magic_plan.handler.MagicPlanClient") as MockClient, \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
MockClient.assert_called_once_with(customer_id="cust-xyz", api_key="key-xyz")
# --- service orchestration ---
def test_handler_calls_service_run_with_address(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
mock_service.run.assert_called_once()
request = mock_service.run.call_args.args[0]
assert request.address == ADDRESS
assert request.uprn is None
def test_handler_passes_uprn_to_service(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "uprn": "100023336956", "hubspot_deal_id": "deal-123"}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
mock_service.run.assert_called_once()
request = mock_service.run.call_args.args[0]
assert request.address == ADDRESS
assert request.uprn == "100023336956"
def test_handler_returns_plan_uid(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
result = _call_handler(body)
# Assert
assert result == PLAN_UID

View file

@ -1,67 +0,0 @@
from typing import Optional
import datatypes.magicplan.api.response as api
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.models import Plan, Floor, Room, Window, Door
def map_plan(mp: MagicPlanPlan) -> Plan:
return Plan(
uid=mp.plan.id,
name=mp.plan.name,
address=_map_address(mp.plan.address),
postcode=mp.plan.address.postal_code if mp.plan.address else None,
floors=[_map_floor(f) for f in mp.plan_detail.plan.floors],
)
def _map_address(addr: Optional[api.Address]) -> Optional[str]:
if addr is None:
return None
street = " ".join(p for p in [addr.street_number, addr.street] if p) or None
parts = [p for p in [street, addr.city, addr.country] if p]
return ", ".join(parts) if parts else None
def _map_floor(f: api.Floor) -> Floor:
return Floor(
level=f.level,
name=f.name,
rooms=[_map_room(r) for r in f.rooms],
)
def _map_room(r: api.Room) -> Room:
width, length = _parse_dimensions(r.dimensions)
return Room(
name=r.name,
width_m=width,
length_m=length,
area_m2=round(r.area, 2),
windows=[
_map_window(wi) for wi in r.wall_items if wi.symbol.id.startswith("window")
],
doors=[_map_door(wi) for wi in r.wall_items if wi.symbol.id.startswith("door")],
)
def _parse_dimensions(dimensions: Optional[str]) -> tuple[float, float]:
if not dimensions:
return 0.0, 0.0
parts = dimensions.split(" x ")
width = round(float(parts[0].split(" m")[0]), 2)
length = round(float(parts[1].split(" m")[0]), 2)
return width, length
def _map_window(wi: api.WallItem) -> Window:
return Window(
width_m=round(wi.size.x, 2),
height_m=round(wi.size.z, 2),
area_m2=round(wi.size.x * wi.size.z, 2),
opening_type=wi.symbol.id.removeprefix("window"),
)
def _map_door(wi: api.WallItem) -> Door:
return Door(width_mm=round(wi.size.x, 2))

View file

@ -1,227 +0,0 @@
import json
from pathlib import Path
from typing import Any
import pytest
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
PLAN_ID_2 = "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365"
@pytest.fixture(scope="module")
def raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlanPlan.model_validate(raw_data)
@pytest.fixture(scope="module")
def plan(mp: MagicPlanPlan) -> Plan:
return map_plan(mp)
def test_plan_uid(plan: Plan):
assert plan.uid == PLAN_ID
def test_floor_count(plan: Plan):
assert len(plan.floors) == 2
def test_first_room_name(plan: Plan):
assert plan.floors[0].rooms[0].name == "Kitchen"
def test_room_dimensions_are_floats(plan: Plan):
room = plan.floors[0].rooms[0]
assert isinstance(room.width_m, float)
assert isinstance(room.length_m, float)
assert isinstance(room.area_m2, float)
def test_room_area_rounded_to_2dp(plan: Plan):
room = plan.floors[0].rooms[0]
assert room.area_m2 == 7.95
def test_room_dimensions_parsed_from_string(plan: Plan):
room = plan.floors[0].rooms[0]
assert room.width_m == pytest.approx(2.67)
assert room.length_m == pytest.approx(2.98)
def test_kitchen_has_windows(plan: Plan):
room = plan.floors[0].rooms[0]
assert len(room.windows) >= 1
def test_window_fields_are_floats(plan: Plan):
window = plan.floors[0].rooms[0].windows[0]
assert isinstance(window.width_m, float)
assert isinstance(window.height_m, float)
assert isinstance(window.area_m2, float)
def test_window_opening_type_prefix_stripped(plan: Plan):
window = plan.floors[0].rooms[0].windows[0]
assert not window.opening_type.startswith("window")
assert window.opening_type == "casement"
def test_window_area_is_width_times_height(plan: Plan):
window = plan.floors[0].rooms[0].windows[0]
assert window.area_m2 == pytest.approx(window.width_m * window.height_m, rel=1e-2)
def test_window_dimensions_rounded_to_2dp(plan: Plan):
window = plan.floors[0].rooms[0].windows[0]
assert window.width_m == 1.40
assert window.height_m == 1.20
assert window.area_m2 == 1.68
def test_door_width_rounded_to_2dp(plan: Plan):
door = plan.floors[0].rooms[0].doors[0]
assert door.width_mm == 0.79
def test_kitchen_has_doors(plan: Plan):
room = plan.floors[0].rooms[0]
assert len(room.doors) >= 1
def test_door_width_is_float(plan: Plan):
door = plan.floors[0].rooms[0].doors[0]
assert isinstance(door.width_mm, float)
# --- Fixture 2: magicplan_api_plan_response_example_2.json ---
@pytest.fixture(scope="module")
def raw_data_2() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_2.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def plan2(raw_data_2: dict[str, Any]) -> Plan:
return map_plan(MagicPlanPlan.model_validate(raw_data_2))
def test_plan2_uid(plan2: Plan):
assert plan2.uid == PLAN_ID_2
def test_plan2_floor_count(plan2: Plan):
assert len(plan2.floors) == 3
def test_plan2_first_room_name(plan2: Plan):
assert plan2.floors[0].rooms[0].name == "Toilet"
def test_plan2_room_area_rounded_to_2dp(plan2: Plan):
room = plan2.floors[0].rooms[0]
assert room.area_m2 == 0.96
def test_plan2_room_dimensions_parsed_from_string(plan2: Plan):
room = plan2.floors[0].rooms[0]
assert room.width_m == pytest.approx(1.12)
assert room.length_m == pytest.approx(0.86)
def test_plan2_room_with_no_windows(plan2: Plan):
hall = plan2.floors[0].rooms[1]
assert hall.name == "Hall"
assert hall.windows == []
def test_plan2_window_dimensions_rounded_to_2dp(plan2: Plan):
window = plan2.floors[0].rooms[0].windows[0]
assert window.width_m == 0.39
assert window.height_m == 0.67
assert window.area_m2 == 0.26
def test_plan2_window_opening_type_casement(plan2: Plan):
window = plan2.floors[0].rooms[0].windows[0]
assert window.opening_type == "casement"
def test_plan2_window_opening_type_hung(plan2: Plan):
bathroom1 = plan2.floors[1].rooms[1]
assert bathroom1.name == "Bathroom 1"
assert bathroom1.windows[0].opening_type == "hung"
def test_plan2_door_width_rounded_to_2dp(plan2: Plan):
door = plan2.floors[0].rooms[0].doors[0]
assert door.width_mm == 0.71
# --- Address and postcode fields ---
def test_plan_postcode(plan: Plan):
assert plan.postcode == "BR2 8BZ"
def test_plan_address(plan: Plan):
assert plan.address == "2 Laburnum Way, Bromley, GB"
def test_plan2_postcode(plan2: Plan):
assert plan2.postcode == "BR1 3LP"
def test_plan2_address(plan2: Plan):
assert plan2.address == "11 Station Road, Bromley, GB"
# --- Fixture 3: street_number set, city absent ---
@pytest.fixture(scope="module")
def plan3() -> Plan:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_3.json").read_text()
)
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan3_address_uses_street_number_and_omits_city(plan3: Plan):
assert plan3.address == "2 Laburnum Way, GB"
def test_plan3_postcode(plan3: Plan):
assert plan3.postcode == "BR2 8BZ"
# --- Fixture 4: street_number set, street absent ---
@pytest.fixture(scope="module")
def plan4() -> Plan:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_4.json").read_text()
)
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan4_address_uses_street_number_when_street_absent(plan4: Plan):
assert plan4.address == "2, Bromley, GB"

View file

@ -37,10 +37,10 @@ module "lambda" {
LOG_LEVEL = "info"
MAGICPLAN_CUSTOMER_ID = var.magicplan_customer_id
MAGICPLAN_API_KEY = var.magicplan_api_key
DB_USERNAME = local.db_credentials.db_assessment_model_username
DB_PASSWORD = local.db_credentials.db_assessment_model_password
DB_HOST = var.db_host
DB_NAME = var.db_name
DB_PORT = var.db_port
POSTGRES_USERNAME = local.db_credentials.db_assessment_model_username
POSTGRES_PASSWORD = local.db_credentials.db_assessment_model_password
POSTGRES_HOST = var.db_host
POSTGRES_DATABASE = var.db_name
POSTGRES_PORT = var.db_port
}
}

128
domain/magicplan/mapper.py Normal file
View file

@ -0,0 +1,128 @@
from typing import Optional
import domain.magicplan.api.response as api
from domain.magicplan.api.response import MagicPlanPlan
from domain.magicplan.models import (
Door,
DoorVentilation,
Floor,
Plan,
Room,
Window,
WindowVentilation,
)
def map_plan(mp: MagicPlanPlan) -> Plan:
return Plan(
uid=mp.plan.id,
name=mp.plan.name,
address=map_address(mp.plan.address),
postcode=mp.plan.address.postal_code if mp.plan.address else None,
floors=[_map_floor(f) for f in mp.plan_detail.plan.floors],
)
def map_address(addr: Optional[api.Address]) -> Optional[str]:
if addr is None:
return None
street = " ".join(p for p in [addr.street_number, addr.street] if p) or None
parts = [p for p in [street, addr.city, addr.country] if p]
return ", ".join(parts) if parts else None
def _map_floor(f: api.Floor) -> Floor:
return Floor(
level=f.level,
name=f.name,
rooms=[_map_room(r) for r in f.rooms],
)
def _map_room(r: api.Room) -> Room:
width, length = _parse_dimensions(r.dimensions)
return Room(
name=r.name,
width_m=width,
length_m=length,
area_m2=round(r.area, 2),
windows=[
_map_window(wi)
for wi in r.wall_items
if wi.symbol.id.startswith("window") or wi.symbol.id == "doorglass"
],
doors=[
_map_door(wi)
for wi in r.wall_items
if wi.symbol.id.startswith("door") and wi.symbol.id != "doorglass"
],
)
def _parse_dimensions(dimensions: Optional[str]) -> tuple[float, float]:
if not dimensions:
return 0.0, 0.0
parts = dimensions.split(" x ")
width = round(float(parts[0].split(" m")[0]), 2)
length = round(float(parts[1].split(" m")[0]), 2)
return width, length
def _map_window(wi: api.WallItem) -> Window:
return Window(
width_m=round(wi.size.x, 2),
height_m=round(wi.size.z, 2),
area_m2=round(wi.size.x * wi.size.z, 2),
ventilation=_map_window_ventilation(wi.custom_displayable_fields),
)
def _map_window_ventilation(
fields: list[api.SurveyField],
) -> Optional[WindowVentilation]:
if not fields:
return None
by_label = {f.label: f for f in fields}
def _str(label: str) -> Optional[str]:
f = by_label.get(label)
if f is None or not f.value.has_value:
return None
v = f.value.value
return v[0] if isinstance(v, list) else v
def _int(label: str) -> Optional[int]:
raw = _str(label)
return int(raw) if raw is not None else None
return WindowVentilation(
opening_type=_str("Opening Type"),
num_openings=_int("Number of Openings (In Same Window)"),
pct_openable=_int("% of Window Openable"),
trickle_vent_area_mm2=_int(
"Trickle Vent Effective Area (mm2) (No Code Then Width x Height)"
),
num_trickle_vents=_int("No. of Trickle Vents"),
)
def _map_door(wi: api.WallItem) -> Door:
return Door(
width_mm=round(wi.size.x * 1000, 0),
height_mm=round(wi.size.z * 1000, 0),
ventilation=_map_door_ventilation(wi.custom_displayable_fields),
)
def _map_door_ventilation(
fields: list[api.SurveyField],
) -> Optional[DoorVentilation]:
if not fields:
return None
by_label = {f.label: f for f in fields}
f = by_label.get("Door Undercut (mm)")
if f is None or not f.value.has_value:
return None
raw = f.value.value
undercut = float(raw[0] if isinstance(raw, list) else raw)
return DoorVentilation(undercut_mm=undercut)

View file

@ -1,4 +1,19 @@
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class WindowVentilation:
opening_type: Optional[str] = None
num_openings: Optional[int] = None
pct_openable: Optional[int] = None
trickle_vent_area_mm2: Optional[int] = None
num_trickle_vents: Optional[int] = None
@dataclass
class DoorVentilation:
undercut_mm: Optional[float] = None
@dataclass
@ -6,12 +21,14 @@ class Window:
width_m: float
height_m: float
area_m2: float
opening_type: str
ventilation: Optional[WindowVentilation] = None
@dataclass
class Door:
width_mm: float # TODO: should this be m or mm?
width_mm: float
height_mm: float
ventilation: Optional[DoorVentilation] = None
@dataclass

View file

@ -0,0 +1,15 @@
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True)
class MagicPlanConfig:
customer_id: str
api_key: str
@classmethod
def from_env(cls, env: Mapping[str, str]) -> "MagicPlanConfig":
return cls(
customer_id=env["MAGICPLAN_CUSTOMER_ID"],
api_key=env["MAGICPLAN_API_KEY"],
)

View file

@ -1,6 +1,6 @@
import requests
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary, PlansListResponse
from domain.magicplan.api.response import MagicPlanPlan, PlanSummary, PlansListResponse
_BASE_URL = "https://cloud.magicplan.app/api/v2"

View file

@ -0,0 +1,144 @@
from __future__ import annotations
from typing import ClassVar, Optional
from sqlmodel import Field, SQLModel
from domain.magicplan.models import (
Door,
DoorVentilation,
Floor,
Plan,
Room,
Window,
WindowVentilation,
)
class MagicPlanPlanModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_plan" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_uid: Optional[str] = Field(default=None, unique=True, index=True)
name: Optional[str] = None
address: Optional[str] = None
postcode: Optional[str] = None
uploaded_file_id: Optional[int] = Field(default=None)
@classmethod
def from_domain(cls, plan: Plan, uploaded_file_id: int) -> "MagicPlanPlanModel":
return cls(
magic_plan_uid=plan.uid,
name=plan.name,
address=plan.address,
postcode=plan.postcode,
uploaded_file_id=uploaded_file_id,
)
class MagicPlanFloorModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_floor" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_plan_id: int = Field(foreign_key="magic_plan_plan.id")
level: Optional[int] = None
@classmethod
def from_domain(cls, floor: Floor, plan_id: int) -> "MagicPlanFloorModel":
return cls(magic_plan_plan_id=plan_id, level=floor.level)
class MagicPlanRoomModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_room" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_floor_id: int = Field(foreign_key="magic_plan_floor.id")
name: Optional[str] = None
width_m: Optional[float] = None
length_m: Optional[float] = None
area_m2: Optional[float] = None
@classmethod
def from_domain(cls, room: Room, floor_id: int) -> "MagicPlanRoomModel":
return cls(
magic_plan_floor_id=floor_id,
name=room.name,
width_m=room.width_m,
length_m=room.length_m,
area_m2=room.area_m2,
)
class MagicPlanWindowModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_window" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_m: Optional[float] = None
height_m: Optional[float] = None
area_m2: Optional[float] = None
@classmethod
def from_domain(cls, window: Window, room_id: int) -> "MagicPlanWindowModel":
return cls(
magic_plan_room_id=room_id,
width_m=window.width_m,
height_m=window.height_m,
area_m2=window.area_m2,
)
class MagicPlanDoorModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_door" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_mm: Optional[float] = None
height_mm: Optional[float] = None
type: Optional[str] = None
@classmethod
def from_domain(cls, door: Door, room_id: int) -> "MagicPlanDoorModel":
return cls(magic_plan_room_id=room_id, width_mm=door.width_mm, height_mm=door.height_mm)
class MagicPlanWindowVentilationModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_window_ventilation" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_window_id: int = Field(foreign_key="magic_plan_window.id")
opening_type: Optional[str] = None
num_openings: Optional[int] = None
pct_openable: Optional[int] = None
trickle_vent_area_mm2: Optional[int] = None
num_trickle_vents: Optional[int] = None
@classmethod
def from_domain(
cls, ventilation: WindowVentilation, window_id: int
) -> "MagicPlanWindowVentilationModel":
return cls(
magic_plan_window_id=window_id,
opening_type=ventilation.opening_type,
num_openings=ventilation.num_openings,
pct_openable=ventilation.pct_openable,
trickle_vent_area_mm2=ventilation.trickle_vent_area_mm2,
num_trickle_vents=ventilation.num_trickle_vents,
)
class MagicPlanDoorVentilationModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "magic_plan_door_ventilation" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_door_id: int = Field(foreign_key="magic_plan_door.id")
undercut_mm: Optional[float] = None
@classmethod
def from_domain(
cls, ventilation: DoorVentilation, door_id: int
) -> "MagicPlanDoorVentilationModel":
return cls(
magic_plan_door_id=door_id,
undercut_mm=ventilation.undercut_mm,
)

View file

@ -1,32 +1,39 @@
import gzip
import json
from datetime import datetime, timezone
import os
from typing import Optional, cast
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from domain.magicplan.api.response import MagicPlanPlan, PlanSummary
from domain.magicplan.mapper import map_plan
from domain.magicplan.models import Plan
from backend.app.db.connection import db_session
from backend.app.db.functions.magic_plan_functions import save_plan
from backend.app.db.models.uploaded_file import (
FileSourceEnum,
FileTypeEnum,
UploadedFile,
)
from backend.magic_plan.address_matcher import find_matching_plan
from backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from utils.logger import setup_logger
from utils.s3 import save_data_to_s3
from applications.magic_plan.address_matcher import find_matching_plan
from infrastructure.postgres.config import PostgresConfig
from infrastructure.postgres.engine import make_engine, make_session
from infrastructure.s3.s3_client import S3Client
from repositories.magic_plan.magic_plan_postgres_repository import (
MagicPlanPostgresRepository,
)
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from utilities.logger import setup_logger
logger = setup_logger()
class MagicPlanService:
def __init__(self, client: MagicPlanClient, s3_bucket: str) -> None:
self._client = client
self._s3_bucket = s3_bucket
class MagicPlanOrchestrator:
def __init__(
self, magic_plan_api_client: MagicPlanClient, s3_client: S3Client
) -> None:
self._api_client = magic_plan_api_client
# self._s3_bucket = s3_bucket
self._s3_client = s3_client
def run(self, request: MagicPlanTriggerRequest) -> Plan:
address = request.address
@ -35,13 +42,13 @@ class MagicPlanService:
if uprn is not None:
logger.info("MagicPlanService.run uprn=%s", uprn)
plans: list[PlanSummary] = self._client.get_plans()
plans: list[PlanSummary] = self._api_client.get_plans()
matched: Optional[PlanSummary] = find_matching_plan(plans, address)
if matched is None:
raise ValueError(f"No MagicPlan found for address: {address!r}")
raw_bytes: bytes = self._client.get_plan_raw(matched.id)
raw_bytes: bytes = self._api_client.get_plan_raw(matched.id)
magic_plan: MagicPlanPlan = MagicPlanPlan.model_validate(
json.loads(raw_bytes)["data"]
)
@ -54,10 +61,14 @@ class MagicPlanService:
hubspot_deal_id=request.hubspot_deal_id,
)
with db_session() as session:
session.add(uploaded_file)
session.flush()
save_plan(session, plan, cast(int, uploaded_file.id))
engine = make_engine(PostgresConfig.from_env(os.environ))
session = make_session(engine)
session.add(uploaded_file)
session.flush()
MagicPlanPostgresRepository(session).save(
plan, cast(int, uploaded_file.id)
) # TODO: refactor to use postgres Unit of Work
return plan
@ -73,9 +84,11 @@ class MagicPlanService:
s3_key = f"documents/uprn/{uprn}/magic_plan_{plan_id}.json.gz"
else:
s3_key = f"documents/hubspot_deal_id/{hubspot_deal_id}/magic_plan_{plan_id}.json.gz"
save_data_to_s3(compressed, self._s3_bucket, s3_key)
self._s3_client.put_object(s3_key, compressed)
return UploadedFile(
s3_file_bucket=self._s3_bucket,
s3_file_bucket=self._s3_client.bucket,
s3_file_key=s3_key,
s3_upload_timestamp=datetime.now(timezone.utc),
uprn=int(uprn) if uprn is not None else None,

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from typing import Any, cast
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlmodel import Session, col
from domain.magicplan.models import Floor, Plan
from infrastructure.postgres.magic_plan_tables import (
MagicPlanDoorModel,
MagicPlanDoorVentilationModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
MagicPlanWindowVentilationModel,
)
from repositories.magic_plan.magic_plan_repository import MagicPlanRepository
class MagicPlanPostgresRepository(MagicPlanRepository):
def __init__(self, session: Session) -> None:
self._session = session
def save(self, plan: Plan, uploaded_file_id: int) -> None:
plan_id = self._upsert_plan(plan, uploaded_file_id)
self._delete_children(plan_id)
floor_ids = self._insert_floors(plan.floors, plan_id)
room_ids = self._insert_rooms(plan.floors, floor_ids)
window_ids, door_ids = self._insert_windows_and_doors(plan.floors, room_ids)
self._insert_ventilation(plan.floors, window_ids, door_ids)
def _upsert_plan(self, plan: Plan, uploaded_file_id: int) -> int:
row_data: dict[str, Any] = MagicPlanPlanModel.from_domain(
plan, uploaded_file_id
).model_dump(exclude={"id"})
stmt = (
pg_insert(MagicPlanPlanModel)
.values(**row_data)
.on_conflict_do_update(
index_elements=["magic_plan_uid"],
set_={k: v for k, v in row_data.items() if k != "magic_plan_uid"},
)
.returning(col(MagicPlanPlanModel.id))
)
return cast(
int, self._session.execute(stmt).scalar_one()
) # pyright: ignore[reportDeprecated]
def _delete_children(self, plan_id: int) -> None:
floor_subq = (
select(col(MagicPlanFloorModel.id))
.where(col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id)
.scalar_subquery()
)
room_subq = (
select(col(MagicPlanRoomModel.id))
.where(col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq))
.scalar_subquery()
)
window_subq = (
select(col(MagicPlanWindowModel.id))
.where(col(MagicPlanWindowModel.magic_plan_room_id).in_(room_subq))
.scalar_subquery()
)
door_subq = (
select(col(MagicPlanDoorModel.id))
.where(col(MagicPlanDoorModel.magic_plan_room_id).in_(room_subq))
.scalar_subquery()
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanWindowVentilationModel).where(
col(MagicPlanWindowVentilationModel.magic_plan_window_id).in_(
window_subq
)
)
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanDoorVentilationModel).where(
col(MagicPlanDoorVentilationModel.magic_plan_door_id).in_(door_subq)
)
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanWindowModel).where(
col(MagicPlanWindowModel.magic_plan_room_id).in_(room_subq)
)
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanDoorModel).where(
col(MagicPlanDoorModel.magic_plan_room_id).in_(room_subq)
)
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanRoomModel).where(
col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq)
)
)
self._session.execute( # pyright: ignore[reportDeprecated]
delete(MagicPlanFloorModel).where(
col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id
)
)
def _insert_floors(self, floors: list[Floor], plan_id: int) -> list[int]:
rows: list[dict[str, Any]] = [
MagicPlanFloorModel.from_domain(floor, plan_id).model_dump(exclude={"id"})
for floor in floors
]
result = self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanFloorModel)
.values(rows)
.returning(col(MagicPlanFloorModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_rooms(self, floors: list[Floor], floor_ids: list[int]) -> list[int]:
rows: list[dict[str, Any]] = [
MagicPlanRoomModel.from_domain(room, floor_id).model_dump(exclude={"id"})
for floor, floor_id in zip(floors, floor_ids)
for room in floor.rooms
]
result = self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanRoomModel)
.values(rows)
.returning(col(MagicPlanRoomModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_windows_and_doors(
self, floors: list[Floor], room_ids: list[int]
) -> tuple[list[int], list[int]]:
all_rooms = [room for floor in floors for room in floor.rooms]
window_rows: list[dict[str, Any]] = [
MagicPlanWindowModel.from_domain(window, room_id).model_dump(exclude={"id"})
for room, room_id in zip(all_rooms, room_ids)
for window in room.windows
]
door_rows: list[dict[str, Any]] = [
MagicPlanDoorModel.from_domain(door, room_id).model_dump(exclude={"id"})
for room, room_id in zip(all_rooms, room_ids)
for door in room.doors
]
window_ids: list[int] = []
door_ids: list[int] = []
if window_rows:
result = self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanWindowModel)
.values(window_rows)
.returning(col(MagicPlanWindowModel.id))
)
window_ids = cast(list[int], list(result.scalars().all()))
if door_rows:
result = self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanDoorModel)
.values(door_rows)
.returning(col(MagicPlanDoorModel.id))
)
door_ids = cast(list[int], list(result.scalars().all()))
return window_ids, door_ids
def _insert_ventilation(
self,
floors: list[Floor],
window_ids: list[int],
door_ids: list[int],
) -> None:
all_rooms = [room for floor in floors for room in floor.rooms]
all_windows = [w for room in all_rooms for w in room.windows]
all_doors = [d for room in all_rooms for d in room.doors]
window_vent_rows: list[dict[str, Any]] = [
MagicPlanWindowVentilationModel.from_domain(w.ventilation, wid).model_dump(
exclude={"id"}
)
for w, wid in zip(all_windows, window_ids)
if w.ventilation is not None
]
door_vent_rows: list[dict[str, Any]] = [
MagicPlanDoorVentilationModel.from_domain(d.ventilation, did).model_dump(
exclude={"id"}
)
for d, did in zip(all_doors, door_ids)
if d.ventilation is not None
]
if window_vent_rows:
self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanWindowVentilationModel).values(window_vent_rows)
)
if door_vent_rows:
self._session.execute( # pyright: ignore[reportDeprecated]
pg_insert(MagicPlanDoorVentilationModel).values(door_vent_rows)
)

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from domain.magicplan.models import Plan
class MagicPlanRepository(ABC):
"""Persists a MagicPlan aggregate.
Implementations are expected to write child rows using bulk inserts
(one round-trip per table) the codebase targets Postgres exclusively.
Upsert semantics on magic_plan_uid; child rows are deleted and re-inserted
on each save (idempotent re-run behaviour per ADR-0012).
"""
@abstractmethod
def save(self, plan: Plan, uploaded_file_id: int) -> None: ...

View file

@ -1,51 +1,182 @@
UPRN,Address,Postcode
U1035052,"1 Sudbury Crescent, Bromley",BR1 4PY
U1027449,"11 Station Road, Bromley",BR1 3LP
U1021310,"126 Faringdon Avenue, Bromley",BR2 8BU
U1010811,"13 Gilbert Road, Bromley",BR1 3QP
U1024017,"13 Manor Way, Bromley",BR2 8ES
U1042232,"154 Southover, Bromley",BR1 4RZ
U1009369,"17 Minster Road, Bromley",BR1 4DY
U1022305,"18a Lansdowne Road, Bromley",BR1 3LZ
U1033165,"2 Laburnum Way, Bromley",BR2 8BZ
U1035326,"2 Whitebeam Avenue, Bromley",BR2 8DL
U1037872,"20 Sudbury Crescent, Bromley",BR1 4PZ
U1007432,"21 Detling Road, Bromley",BR1 4SH
U1005123,"24 Bonville Road, Bromley",BR1 4QA
U1034810,"24 Newbury Road, Bromley",BR2 0QW
U1020351,"27 Laburnum Way, Bromley",BR2 8BY
U1009511,"27 Newbury Road, Bromley",BR2 0QN
U1034985,"272 Southborough Lane, Bromley",BR2 8AS
U1037954,"28 Treewall Gardens, Bromley",BR1 5BT
U1038103,"29 Whitebeam Avenue, Bromley",BR2 8DJ
U1013358,"3 Bird In Hand Lane, Bromley",BR1 2NA
U1024709,"3 Parkfield Way, Bromley",BR2 8AE
U1031058,"303 Keedonwood Road, Bromley",BR1 4QR
U1014077,"32 Aylesbury Road, Bromley",BR2 0QP
U1019564,"32 Brook Lane, Bromley",BR1 4PU
U1020237,"33 Hornbeam Way, Bromley",BR2 8DB
U1027392,"26 Silverdale Road, Oprington",BR5 2LT
U1003906,"54 Barnsdale Crescent, Oprington",BR5 2AX
U1034479,"90 Mead Way, Bromley",BR2 9EU
U1005549,"79 Lower Gravel Road, Bromley",BR2 8LP
U1016743,"16 Princes Plain, Bromley",BR2 8LE
U1041937,"75 Turpington Lane, Bromley",BR2 8JD
U1034805,"38 Narrow Way, Bromley",BR2 8JB
U1041933,"31 Turpington Lane, Bromley",BR2 8JA
U1037833,"3 Stiles Close, Bromley",BR2 8EQ
U1042734,"86 Whitebeam Avenue, Bromley",BR2 8DW
U1042575,"90 Whitebeam Avenue, Bromley",BR2 8DW
U1033177,"30 Larch Way, Bromley",BR2 8DU
U1027989,"5 Larch Way, Bromley",BR2 8DT
U1012309,"13 Almond Close, Bromley",BR2 8DS
U1022525,"30 Lovelace Avenue, Bromley",BR2 8DQ
U1047613,"13 Whitebeam Avenue, Bromley",BR2 8DJ
U1027549,"14 Thorn Close, Bromley",BR2 8DH
U1022726,"31 Hornbeam Way, Bromley",BR2 8DB
U1021308,"70 Faringdon Avenue, Bromley",BR2 8BU
U1026958,"77 Faringdon Avenue, Bromley",BR2 8BT
U1007553,"115d Faringdon Avenue, Bromley",BR2 8BT
U1032132,"115f Faringdon Avenue, Bromley",BR2 8BT
U1014627,"81 Faringdon Avenue, Bromley",BR2 8BT
U1009607,"20 Parkfield Way, Bromley",BR2 8AF
U1027838,"15 Holmcroft Way, Bromley",BR2 8AD
U1052376,"3 Shoreham Way, Bromley",BR2 7PU
U1015499,"25 Boughton Avenue, Bromley",BR2 7PL
U1005741,"2 Malling Way, Bromley",BR2 7PJ
U1019906,"26 Eastry Avenue, Bromley",BR2 7PF
U1021769,"49 Eastry Avenue, Bromley",BR2 7PE
U1031121,"32 Laburnum Way, Bromley",BR2 8BZ
U1022281,"35 Kingsdown Way, Bromley",BR2 7PT
U1035115,"30 Thorn Close, Bromley",BR2 8DH
U1027493,"35 Sudbury Crescent, Bromley",BR1 4PY
U1042298,"39 Sudbury Crescent, Bromley",BR1 4PY
U1016746,"68 Princes Plain, Bromley",BR2 8LE
U1005168,"38 Holbrook Way, Bromley",BR2 8EE
U1036446,"14 Meath Close, Oprington",BR5 2HF
U1010452,"5 Canbury Path, Oprington",BR5 2EU
U1019785,"50 Cray Valley Road, Oprington",BR5 2EZ
U1024065,"64 Marion Crescent, Oprington",BR5 2HD
U1042248,"16 Stanley Way, Oprington",BR5 2HE
U1029229,"2 Meath Close, Oprington",BR5 2HF
U1037768,"13 Silverdale Road, Oprington",BR5 2LU
U1014589,"71 Empress Drive, Chislehurst",BR7 5BQ
U1024698,"4 Palace View, Bromley",BR1 3EL
U1052186,"4 Ravensleigh Gardens, Bromley",BR1 5SN
U1052536,"12 Thorn Close, Bromley",BR2 8DH
U1022018,"12 Hazel Walk, Bromley",BR2 8DF
U1007728,"2 Hazel Walk, Bromley",BR2 8DF
U1002456,"54 Birch Row, Bromley",BR2 8DA
U1020349,"21 Laburnum Way, Bromley",BR2 8BY
U1032129,"78 Faringdon Avenue, Bromley",BR2 8BU
U1032130,"86 Faringdon Avenue, Bromley",BR2 8BU
U1021824,"115g Faringdon Avenue, Bromley",BR2 8BT
U1021827,"121 Faringdon Avenue, Bromley",BR2 8BT
U1026960,"105 Faringdon Avenue, Bromley",BR2 8BT
U1010578,"6 Cranbrook Close, Bromley",BR2 7QA
U1024709,"3 Parkfield Way, Bromley",BR2 8AE
U1024580,"24 Parkfield Way, Bromley",BR2 8AF
U1011190,"11 Shoreham Way, Bromley",BR2 7PU
U1011191,"32 Shoreham Way, Bromley",BR2 7PU
U1021535,"4 Chilham Way, Bromley",BR2 7PR
U1007556,"11 Farleigh Avenue, Bromley",BR2 7PP
U1010255,"7 Boughton Avenue, Bromley",BR2 7PL
U1034810,"24 Newbury Road, Bromley",BR2 0QW
U1009032,"10 Malling Way, Bromley",BR2 7PJ
U1004686,"55 Baston Road, Bromley",BR2 7BD
U1032061,"30 Eastry Avenue, Bromley",BR2 7PF
U1010582,"13 Cranworth Cottages, Keston",BR2 6DB
U1009511,"27 Newbury Road, Bromley",BR2 0QN
U1037954,"28 Treewall Gardens, Bromley",BR1 5BT
U1014793,"59 Headcorn Road, Bromley",BR1 4SQ
U1005123,"24 Bonville Road, Bromley",BR1 4QA
U1037872,"20 Sudbury Crescent, Bromley",BR1 4PZ
U1022305,"18a Lansdowne Road, Bromley",BR1 3LZ
U1035052,"1 Sudbury Crescent, Bromley",BR1 4PY
U1042298,"39 Sudbury Crescent, Bromley",BR1 4PY
U1019564,"32 Brook Lane, Bromley",BR1 4PU
U1024511,"81 Nightingale Lane, Bromley",BR1 2SA
U1032133,"119 Faringdon Avenue, Bromley",BR2 8BT
U1032134,"125 Faringdon Avenue, Bromley",BR2 8BT
U1032131,"93 Faringdon Avenue, Bromley",BR2 8BT
U1021825,"117a Faringdon Avenue, Bromley",BR2 8BT
U1010107,"42 Birch Row, Bromley",BR2 8DA
U1016880,"25 Almond Way, Bromley",BR2 8DR
U1038107,"152 Whitebeam Avenue, Bromley",BR2 8DW
U1052455,"10 Stiles Close, Bromley",BR2 8EQ
U1028328,"76 Magpie Hall Lane, Bromley",BR2 8ER
U1020064,"28 Green Way, Bromley",BR2 8EY
U1041934,"51 Turpington Lane, Bromley",BR2 8JA
U1005696,"158 Magpie Hall Lane, Bromley",BR2 8JG
U1030892,"140 Poverest Road, Oprington",BR5 1RH
U1011072,"45 Rookery Gardens, Oprington",BR5 4BA
U1031058,"303 Keedonwood Road, Bromley",BR1 4QR
U1052429,"76 Southover, Bromley",BR1 4RY
U1042153,"4 Scotts Road, Bromley",BR1 3QD
U1037814,"42 Stanley Road, Bromley",BR2 9JH
U1014078,"43 Aylesbury Road, Bromley",BR2 0QR
U1007701,"46 Harwood Avenue, Bromley",BR1 3DU
U1036758,"46 Newbury Road, Bromley",BR2 0QW
U1025820,"46 Princes Plain, Bromley",BR2 8LE
U1022991,"5 Link Way, Bromley",BR2 8JH
U1024484,"55 Mounthurst Road, Bromley",BR2 7PG
U1014793,"59 Headcorn Road, Bromley",BR1 4SQ
U1008158,"71 Lower Gravel Road, Bromley",BR2 8LP
U1032062,"46 Eastry Avenue, Bromley",BR2 7PF
U1016742,"4 Princes Plain, Bromley",BR2 8LE
U1038106,"84 Whitebeam Avenue, Bromley",BR2 8DW
U1013831,"33 Ash Row, Bromley",BR2 8DZ
U1005742,"18 Malling Way, Bromley",BR2 7PJ
U1042572,"37 Whitebeam Avenue, Bromley",BR2 8DJ
U1005167,"30 Holbrook Way, Bromley",BR2 8EE
U1024581,"30 Parkfield Way, Bromley",BR2 8AF
U1024050,"10 Marden Avenue, Bromley",BR2 7PX
U1020328,"31 Kingsdown Way, Bromley",BR2 7PT
U1020327,"5 Kingsdown Way, Bromley",BR2 7PT
U1005131,"10 Boughton Avenue, Bromley",BR2 7PL
U1023415,"27 Malling Way, Bromley",BR2 7PJ
U1052580,"25 Trentham Drive, Oprington",BR5 2EP
U1026447,"50 Princes Plain, Bromley",BR2 8LE
U1007432,"21 Detling Road, Bromley",BR1 4SH
U1053388,"52 Whitebeam Avenue, Bromley",BR2 8DL
U1022307,"20 Larch Way, Bromley",BR2 8DU
U1027743,"17 Whitebeam Avenue, Bromley",BR2 8DJ
U1022533,"81 Lower Gravel Road, Bromley",BR2 8LP
U1014630,"118 Faringdon Avenue, Bromley",BR2 8BU
U1030897,"60 Princes Plain, Bromley",BR2 8LE
U1022931,"15 Lennard Road, Bromley",BR2 8LN
U1042735,"108 Whitebeam Avenue, Bromley",BR2 8DW
U1013830,"31 Ash Row, Bromley",BR2 8DZ
U1020368,"11 Larch Way, Bromley",BR2 8DT
U1020369,"13 Larch Way, Bromley",BR2 8DT
U1042576,"112 Whitebeam Avenue, Bromley",BR2 8DW
U1027830,"26 Holbrook Way, Bromley",BR2 8EE
U1016744,"18 Princes Plain, Bromley",BR2 8LE
U1041772,"13 Stanley Road, Bromley",BR2 9JE
U1042573,"47 Whitebeam Avenue, Bromley",BR2 8DJ
U1008151,"61 Lovelace Avenue, Bromley",BR2 8EA
U1034985,"272 Southborough Lane, Bromley",BR2 8AS
U1007947,"8 Laburnum Way, Bromley",BR2 8BZ
U1027744,"33 Whitebeam Avenue, Bromley",BR2 8DJ
U1038102,"14 Whitebeam Avenue, Bromley",BR2 8DL
U1027745,"148 Whitebeam Avenue, Bromley",BR2 8DW
U1020208,"16 Holbrook Way, Bromley",BR2 8EE
U1023463,"30 MANOR WAY, Bromley",BR2 8ES
U1032647,"32 Narrow Way, Bromley",BR2 8JB
U1033406,"67 Lower Gravel Road, Bromley",BR2 8LP
U1000649,"42 Barnsdale Crescent, Oprington",BR5 2AX
U1036332,"50 Marion Crescent, Oprington",BR5 2HD
U1038103,"29 Whitebeam Avenue, Bromley",BR2 8DJ
U1042574,"66 Whitebeam Avenue, Bromley",BR2 8DL
U1042256,"2 Stiles Close, Bromley",BR2 8EQ
U1034017,"48 Princes Plain, Bromley",BR2 8LE
U1041435,"64 Princes Plain, Bromley",BR2 8LE
U1053386,"27 Whitebeam Avenue, Bromley",BR2 8DJ
U1038104,"32 Whitebeam Avenue, Bromley",BR2 8DL
U1019567,"8 Broom Close, Bromley",BR2 8EU
U1023539,"2 Marsham Close, Chislehurst",BR7 6JD
U1037465,"6 Princes Plain, Bromley",BR2 8LE
U1009202,"63 Mead Way, Bromley",BR2 9ER
U1021353,"66 George Lane, Bromley",BR2 7LQ
U1042733,"68 Whitebeam Avenue, Bromley",BR2 8DL
U1030962,"7 Ravensleigh Gardens, Bromley",BR1 5SN
U1031294,"70 London Lane, Bromley",BR1 4HE
U1004442,"7 Birch Row, Bromley",BR2 8BX
U1005006,"44 Birch Row, Bromley",BR2 8DA
U1035331,"104 Whitebeam Avenue, Bromley",BR2 8DW
U1024017,"13 Manor Way, Bromley",BR2 8ES
U1014078,"43 Aylesbury Road, Bromley",BR2 0QR
U1052186,"4 Ravensleigh Gardens, Bromley",BR1 5SN
U1037450,"70 Pontefract Road, Bromley",BR1 4RB
U1014589,"71 Empress Drive, Chislehurst",BR7 5BQ
U1052429,"76 Southover, Bromley",BR1 4RY
U1031294,"70 London Lane, Bromley",BR1 4HE
U1014077,"32 Aylesbury Road, Bromley",BR2 0QP
U1030962,"7 Ravensleigh Gardens, Bromley",BR1 5SN
U1042232,"154 Southover, Bromley",BR1 4RZ
U1024484,"55 Mounthurst Road, Bromley",BR2 7PG
U1007701,"46 Harwood Avenue, Bromley",BR1 3DU
U1020199,"78 Hillside Road, Bromley",BR2 0ST
U1024511,"81 Nightingale Lane, Bromley",BR1 2SA
U1036758,"46 Newbury Road, Bromley",BR2 0QW
U1009369,"17 Minster Road, Bromley",BR1 4DY
U1009194,"84 Mays Hill Road, Bromley",BR2 0HT
U1013358,"3 Bird In Hand Lane, Bromley",BR1 2NA
U1009202,"63 Mead Way, Bromley",BR2 9ER
U1022991,"5 Link Way, Bromley",BR2 8JH
U1025820,"46 Princes Plain, Bromley",BR2 8LE
U1020237,"33 Hornbeam Way, Bromley",BR2 8DB
U1021310,"126 Faringdon Avenue, Bromley",BR2 8BU
U1021353,"66 George Lane, Bromley",BR2 7LQ
U1033165,"2 Laburnum Way, Bromley",BR2 8BZ
U1010811,"13 Gilbert Road, Bromley",BR1 3QP
U1027449,"11 Station Road, Bromley",BR1 3LP
U1035326,"2 Whitebeam Avenue, Bromley",BR2 8DL
U1020351,"27 Laburnum Way, Bromley",BR2 8BY

1 UPRN Address Postcode
2 U1035052 U1027392 1 Sudbury Crescent, Bromley 26 Silverdale Road, Oprington BR1 4PY BR5 2LT
3 U1027449 U1003906 11 Station Road, Bromley 54 Barnsdale Crescent, Oprington BR1 3LP BR5 2AX
4 U1021310 U1034479 126 Faringdon Avenue, Bromley 90 Mead Way, Bromley BR2 8BU BR2 9EU
5 U1010811 U1005549 13 Gilbert Road, Bromley 79 Lower Gravel Road, Bromley BR1 3QP BR2 8LP
6 U1024017 U1016743 13 Manor Way, Bromley 16 Princes Plain, Bromley BR2 8ES BR2 8LE
7 U1042232 U1041937 154 Southover, Bromley 75 Turpington Lane, Bromley BR1 4RZ BR2 8JD
8 U1009369 U1034805 17 Minster Road, Bromley 38 Narrow Way, Bromley BR1 4DY BR2 8JB
9 U1022305 U1041933 18a Lansdowne Road, Bromley 31 Turpington Lane, Bromley BR1 3LZ BR2 8JA
10 U1033165 U1037833 2 Laburnum Way, Bromley 3 Stiles Close, Bromley BR2 8BZ BR2 8EQ
11 U1035326 U1042734 2 Whitebeam Avenue, Bromley 86 Whitebeam Avenue, Bromley BR2 8DL BR2 8DW
12 U1037872 U1042575 20 Sudbury Crescent, Bromley 90 Whitebeam Avenue, Bromley BR1 4PZ BR2 8DW
13 U1007432 U1033177 21 Detling Road, Bromley 30 Larch Way, Bromley BR1 4SH BR2 8DU
14 U1005123 U1027989 24 Bonville Road, Bromley 5 Larch Way, Bromley BR1 4QA BR2 8DT
15 U1034810 U1012309 24 Newbury Road, Bromley 13 Almond Close, Bromley BR2 0QW BR2 8DS
16 U1020351 U1022525 27 Laburnum Way, Bromley 30 Lovelace Avenue, Bromley BR2 8BY BR2 8DQ
17 U1009511 U1047613 27 Newbury Road, Bromley 13 Whitebeam Avenue, Bromley BR2 0QN BR2 8DJ
18 U1034985 U1027549 272 Southborough Lane, Bromley 14 Thorn Close, Bromley BR2 8AS BR2 8DH
19 U1037954 U1022726 28 Treewall Gardens, Bromley 31 Hornbeam Way, Bromley BR1 5BT BR2 8DB
20 U1038103 U1021308 29 Whitebeam Avenue, Bromley 70 Faringdon Avenue, Bromley BR2 8DJ BR2 8BU
21 U1013358 U1026958 3 Bird In Hand Lane, Bromley 77 Faringdon Avenue, Bromley BR1 2NA BR2 8BT
22 U1024709 U1007553 3 Parkfield Way, Bromley 115d Faringdon Avenue, Bromley BR2 8AE BR2 8BT
23 U1031058 U1032132 303 Keedonwood Road, Bromley 115f Faringdon Avenue, Bromley BR1 4QR BR2 8BT
24 U1014077 U1014627 32 Aylesbury Road, Bromley 81 Faringdon Avenue, Bromley BR2 0QP BR2 8BT
25 U1019564 U1009607 32 Brook Lane, Bromley 20 Parkfield Way, Bromley BR1 4PU BR2 8AF
26 U1020237 U1027838 33 Hornbeam Way, Bromley 15 Holmcroft Way, Bromley BR2 8DB BR2 8AD
27 U1052376 3 Shoreham Way, Bromley BR2 7PU
28 U1015499 25 Boughton Avenue, Bromley BR2 7PL
29 U1005741 2 Malling Way, Bromley BR2 7PJ
30 U1019906 26 Eastry Avenue, Bromley BR2 7PF
31 U1021769 49 Eastry Avenue, Bromley BR2 7PE
32 U1031121 32 Laburnum Way, Bromley BR2 8BZ
33 U1022281 35 Kingsdown Way, Bromley BR2 7PT
34 U1035115 30 Thorn Close, Bromley BR2 8DH
35 U1027493 35 Sudbury Crescent, Bromley BR1 4PY
36 U1042298 U1016746 39 Sudbury Crescent, Bromley 68 Princes Plain, Bromley BR1 4PY BR2 8LE
37 U1005168 38 Holbrook Way, Bromley BR2 8EE
38 U1036446 14 Meath Close, Oprington BR5 2HF
39 U1010452 5 Canbury Path, Oprington BR5 2EU
40 U1019785 50 Cray Valley Road, Oprington BR5 2EZ
41 U1024065 64 Marion Crescent, Oprington BR5 2HD
42 U1042248 16 Stanley Way, Oprington BR5 2HE
43 U1029229 2 Meath Close, Oprington BR5 2HF
44 U1037768 13 Silverdale Road, Oprington BR5 2LU
45 U1014589 71 Empress Drive, Chislehurst BR7 5BQ
46 U1024698 4 Palace View, Bromley BR1 3EL
47 U1052186 U1052536 4 Ravensleigh Gardens, Bromley 12 Thorn Close, Bromley BR1 5SN BR2 8DH
48 U1022018 12 Hazel Walk, Bromley BR2 8DF
49 U1007728 2 Hazel Walk, Bromley BR2 8DF
50 U1002456 54 Birch Row, Bromley BR2 8DA
51 U1020349 21 Laburnum Way, Bromley BR2 8BY
52 U1032129 78 Faringdon Avenue, Bromley BR2 8BU
53 U1032130 86 Faringdon Avenue, Bromley BR2 8BU
54 U1021824 115g Faringdon Avenue, Bromley BR2 8BT
55 U1021827 121 Faringdon Avenue, Bromley BR2 8BT
56 U1026960 105 Faringdon Avenue, Bromley BR2 8BT
57 U1010578 6 Cranbrook Close, Bromley BR2 7QA
58 U1024709 3 Parkfield Way, Bromley BR2 8AE
59 U1024580 24 Parkfield Way, Bromley BR2 8AF
60 U1011190 11 Shoreham Way, Bromley BR2 7PU
61 U1011191 32 Shoreham Way, Bromley BR2 7PU
62 U1021535 4 Chilham Way, Bromley BR2 7PR
63 U1007556 11 Farleigh Avenue, Bromley BR2 7PP
64 U1010255 7 Boughton Avenue, Bromley BR2 7PL
65 U1034810 24 Newbury Road, Bromley BR2 0QW
66 U1009032 10 Malling Way, Bromley BR2 7PJ
67 U1004686 55 Baston Road, Bromley BR2 7BD
68 U1032061 30 Eastry Avenue, Bromley BR2 7PF
69 U1010582 13 Cranworth Cottages, Keston BR2 6DB
70 U1009511 27 Newbury Road, Bromley BR2 0QN
71 U1037954 28 Treewall Gardens, Bromley BR1 5BT
72 U1014793 59 Headcorn Road, Bromley BR1 4SQ
73 U1005123 24 Bonville Road, Bromley BR1 4QA
74 U1037872 20 Sudbury Crescent, Bromley BR1 4PZ
75 U1022305 18a Lansdowne Road, Bromley BR1 3LZ
76 U1035052 1 Sudbury Crescent, Bromley BR1 4PY
77 U1042298 39 Sudbury Crescent, Bromley BR1 4PY
78 U1019564 32 Brook Lane, Bromley BR1 4PU
79 U1024511 81 Nightingale Lane, Bromley BR1 2SA
80 U1032133 119 Faringdon Avenue, Bromley BR2 8BT
81 U1032134 125 Faringdon Avenue, Bromley BR2 8BT
82 U1032131 93 Faringdon Avenue, Bromley BR2 8BT
83 U1021825 117a Faringdon Avenue, Bromley BR2 8BT
84 U1010107 42 Birch Row, Bromley BR2 8DA
85 U1016880 25 Almond Way, Bromley BR2 8DR
86 U1038107 152 Whitebeam Avenue, Bromley BR2 8DW
87 U1052455 10 Stiles Close, Bromley BR2 8EQ
88 U1028328 76 Magpie Hall Lane, Bromley BR2 8ER
89 U1020064 28 Green Way, Bromley BR2 8EY
90 U1041934 51 Turpington Lane, Bromley BR2 8JA
91 U1005696 158 Magpie Hall Lane, Bromley BR2 8JG
92 U1030892 140 Poverest Road, Oprington BR5 1RH
93 U1011072 45 Rookery Gardens, Oprington BR5 4BA
94 U1031058 303 Keedonwood Road, Bromley BR1 4QR
95 U1052429 76 Southover, Bromley BR1 4RY
96 U1042153 4 Scotts Road, Bromley BR1 3QD
97 U1037814 42 Stanley Road, Bromley BR2 9JH
98 U1014078 U1008158 43 Aylesbury Road, Bromley 71 Lower Gravel Road, Bromley BR2 0QR BR2 8LP
99 U1007701 U1032062 46 Harwood Avenue, Bromley 46 Eastry Avenue, Bromley BR1 3DU BR2 7PF
100 U1036758 U1016742 46 Newbury Road, Bromley 4 Princes Plain, Bromley BR2 0QW BR2 8LE
101 U1025820 U1038106 46 Princes Plain, Bromley 84 Whitebeam Avenue, Bromley BR2 8LE BR2 8DW
102 U1022991 U1013831 5 Link Way, Bromley 33 Ash Row, Bromley BR2 8JH BR2 8DZ
103 U1024484 U1005742 55 Mounthurst Road, Bromley 18 Malling Way, Bromley BR2 7PG BR2 7PJ
104 U1014793 U1042572 59 Headcorn Road, Bromley 37 Whitebeam Avenue, Bromley BR1 4SQ BR2 8DJ
105 U1005167 30 Holbrook Way, Bromley BR2 8EE
106 U1024581 30 Parkfield Way, Bromley BR2 8AF
107 U1024050 10 Marden Avenue, Bromley BR2 7PX
108 U1020328 31 Kingsdown Way, Bromley BR2 7PT
109 U1020327 5 Kingsdown Way, Bromley BR2 7PT
110 U1005131 10 Boughton Avenue, Bromley BR2 7PL
111 U1023415 27 Malling Way, Bromley BR2 7PJ
112 U1052580 25 Trentham Drive, Oprington BR5 2EP
113 U1026447 50 Princes Plain, Bromley BR2 8LE
114 U1007432 21 Detling Road, Bromley BR1 4SH
115 U1053388 52 Whitebeam Avenue, Bromley BR2 8DL
116 U1022307 20 Larch Way, Bromley BR2 8DU
117 U1027743 17 Whitebeam Avenue, Bromley BR2 8DJ
118 U1022533 81 Lower Gravel Road, Bromley BR2 8LP
119 U1014630 118 Faringdon Avenue, Bromley BR2 8BU
120 U1030897 60 Princes Plain, Bromley BR2 8LE
121 U1022931 15 Lennard Road, Bromley BR2 8LN
122 U1042735 108 Whitebeam Avenue, Bromley BR2 8DW
123 U1013830 31 Ash Row, Bromley BR2 8DZ
124 U1020368 11 Larch Way, Bromley BR2 8DT
125 U1020369 13 Larch Way, Bromley BR2 8DT
126 U1042576 112 Whitebeam Avenue, Bromley BR2 8DW
127 U1027830 26 Holbrook Way, Bromley BR2 8EE
128 U1016744 18 Princes Plain, Bromley BR2 8LE
129 U1041772 13 Stanley Road, Bromley BR2 9JE
130 U1042573 47 Whitebeam Avenue, Bromley BR2 8DJ
131 U1008151 61 Lovelace Avenue, Bromley BR2 8EA
132 U1034985 272 Southborough Lane, Bromley BR2 8AS
133 U1007947 8 Laburnum Way, Bromley BR2 8BZ
134 U1027744 33 Whitebeam Avenue, Bromley BR2 8DJ
135 U1038102 14 Whitebeam Avenue, Bromley BR2 8DL
136 U1027745 148 Whitebeam Avenue, Bromley BR2 8DW
137 U1020208 16 Holbrook Way, Bromley BR2 8EE
138 U1023463 30 MANOR WAY, Bromley BR2 8ES
139 U1032647 32 Narrow Way, Bromley BR2 8JB
140 U1033406 67 Lower Gravel Road, Bromley BR2 8LP
141 U1000649 42 Barnsdale Crescent, Oprington BR5 2AX
142 U1036332 50 Marion Crescent, Oprington BR5 2HD
143 U1038103 29 Whitebeam Avenue, Bromley BR2 8DJ
144 U1042574 66 Whitebeam Avenue, Bromley BR2 8DL
145 U1042256 2 Stiles Close, Bromley BR2 8EQ
146 U1034017 48 Princes Plain, Bromley BR2 8LE
147 U1041435 64 Princes Plain, Bromley BR2 8LE
148 U1053386 27 Whitebeam Avenue, Bromley BR2 8DJ
149 U1038104 32 Whitebeam Avenue, Bromley BR2 8DL
150 U1019567 8 Broom Close, Bromley BR2 8EU
151 U1023539 2 Marsham Close, Chislehurst BR7 6JD
152 U1037465 6 Princes Plain, Bromley BR2 8LE
U1009202 63 Mead Way, Bromley BR2 9ER
U1021353 66 George Lane, Bromley BR2 7LQ
153 U1042733 68 Whitebeam Avenue, Bromley BR2 8DL
154 U1030962 U1004442 7 Ravensleigh Gardens, Bromley 7 Birch Row, Bromley BR1 5SN BR2 8BX
155 U1031294 U1005006 70 London Lane, Bromley 44 Birch Row, Bromley BR1 4HE BR2 8DA
156 U1035331 104 Whitebeam Avenue, Bromley BR2 8DW
157 U1024017 13 Manor Way, Bromley BR2 8ES
158 U1014078 43 Aylesbury Road, Bromley BR2 0QR
159 U1052186 4 Ravensleigh Gardens, Bromley BR1 5SN
160 U1037450 70 Pontefract Road, Bromley BR1 4RB
161 U1014589 U1031294 71 Empress Drive, Chislehurst 70 London Lane, Bromley BR7 5BQ BR1 4HE
162 U1052429 U1014077 76 Southover, Bromley 32 Aylesbury Road, Bromley BR1 4RY BR2 0QP
163 U1030962 7 Ravensleigh Gardens, Bromley BR1 5SN
164 U1042232 154 Southover, Bromley BR1 4RZ
165 U1024484 55 Mounthurst Road, Bromley BR2 7PG
166 U1007701 46 Harwood Avenue, Bromley BR1 3DU
167 U1020199 78 Hillside Road, Bromley BR2 0ST
168 U1024511 U1036758 81 Nightingale Lane, Bromley 46 Newbury Road, Bromley BR1 2SA BR2 0QW
169 U1009369 17 Minster Road, Bromley BR1 4DY
170 U1009194 84 Mays Hill Road, Bromley BR2 0HT
171 U1013358 3 Bird In Hand Lane, Bromley BR1 2NA
172 U1009202 63 Mead Way, Bromley BR2 9ER
173 U1022991 5 Link Way, Bromley BR2 8JH
174 U1025820 46 Princes Plain, Bromley BR2 8LE
175 U1020237 33 Hornbeam Way, Bromley BR2 8DB
176 U1021310 126 Faringdon Avenue, Bromley BR2 8BU
177 U1021353 66 George Lane, Bromley BR2 7LQ
178 U1033165 2 Laburnum Way, Bromley BR2 8BZ
179 U1010811 13 Gilbert Road, Bromley BR1 3QP
180 U1027449 11 Station Road, Bromley BR1 3LP
181 U1035326 2 Whitebeam Avenue, Bromley BR2 8DL
182 U1020351 27 Laburnum Way, Bromley BR2 8BY

View file

@ -0,0 +1,91 @@
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from applications.magic_plan.handler import handler
ADDRESS = "2 Laburnum Way Bromley BR2 8BZ"
PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
_ENV = {"MAGICPLAN_CUSTOMER_ID": "cust-123", "MAGICPLAN_API_KEY": "key-abc"}
def _call_handler(body: dict[str, Any]) -> Any:
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
@pytest.fixture()
def mock_plan() -> MagicMock:
plan = MagicMock()
plan.uid = PLAN_UID
return plan
@pytest.fixture()
def mock_orchestrator(mock_plan: MagicMock) -> MagicMock:
orchestrator = MagicMock()
orchestrator.run.return_value = mock_plan
return orchestrator
# --- request validation ---
def test_handler_raises_on_missing_address(mock_plan: MagicMock) -> None:
body: dict[str, Any] = {}
with patch("applications.magic_plan.handler.os.environ", _ENV), \
patch("applications.magic_plan.handler.MagicPlanClient"), \
patch("applications.magic_plan.handler.MagicPlanOrchestrator"):
with pytest.raises(ValidationError):
_call_handler(body)
# --- client construction ---
def test_handler_constructs_client_from_env(mock_orchestrator: MagicMock) -> None:
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
env = {"MAGICPLAN_CUSTOMER_ID": "cust-xyz", "MAGICPLAN_API_KEY": "key-xyz"}
with patch("applications.magic_plan.handler.os.environ", env), \
patch("applications.magic_plan.handler.MagicPlanClient") as MockClient, \
patch("applications.magic_plan.handler.MagicPlanOrchestrator", return_value=mock_orchestrator):
_call_handler(body)
MockClient.assert_called_once_with(customer_id="cust-xyz", api_key="key-xyz")
# --- orchestrator orchestration ---
def test_handler_calls_orchestrator_run_with_address(mock_orchestrator: MagicMock) -> None:
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
with patch("applications.magic_plan.handler.os.environ", _ENV), \
patch("applications.magic_plan.handler.MagicPlanClient"), \
patch("applications.magic_plan.handler.MagicPlanOrchestrator", return_value=mock_orchestrator):
_call_handler(body)
mock_orchestrator.run.assert_called_once()
request = mock_orchestrator.run.call_args.args[0]
assert request.address == ADDRESS
assert request.uprn is None
def test_handler_passes_uprn_to_orchestrator(mock_orchestrator: MagicMock) -> None:
body = {"address": ADDRESS, "uprn": "100023336956", "hubspot_deal_id": "deal-123"}
with patch("applications.magic_plan.handler.os.environ", _ENV), \
patch("applications.magic_plan.handler.MagicPlanClient"), \
patch("applications.magic_plan.handler.MagicPlanOrchestrator", return_value=mock_orchestrator):
_call_handler(body)
mock_orchestrator.run.assert_called_once()
request = mock_orchestrator.run.call_args.args[0]
assert request.address == ADDRESS
assert request.uprn == "100023336956"
def test_handler_returns_plan_uid(mock_orchestrator: MagicMock) -> None:
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
with patch("applications.magic_plan.handler.os.environ", _ENV), \
patch("applications.magic_plan.handler.MagicPlanClient"), \
patch("applications.magic_plan.handler.MagicPlanOrchestrator", return_value=mock_orchestrator):
result = _call_handler(body)
assert result == PLAN_UID

View file

@ -1,7 +1,7 @@
import pytest
from pydantic import ValidationError
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
def test_valid_payload_with_address_only() -> None:

View file

@ -0,0 +1,231 @@
import json
from pathlib import Path
from typing import Any
import pytest
import domain.magicplan.api.response as api
from domain.magicplan.api.response import MagicPlanPlan, Symbol, Vec3, WallItem
from domain.magicplan.mapper import (
_map_window, # pyright: ignore[reportPrivateUsage]
map_address,
map_plan,
)
from domain.magicplan.models import Plan
FIXTURE_DIR = Path(__file__).parents[3] / "tests" / "magic_plan"
PLAN_ID = "72efd2e0-b2b9-48cd-b82e-41f5b3166c9a"
@pytest.fixture(scope="module")
def raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlanPlan.model_validate(raw_data)
@pytest.fixture(scope="module")
def plan(mp: MagicPlanPlan) -> Plan:
return map_plan(mp)
# --- Plan-level ---
def test_plan_uid(plan: Plan) -> None:
assert plan.uid == PLAN_ID
def test_plan_postcode(plan: Plan) -> None:
assert plan.postcode == "BR2 8DU"
def test_plan_address(plan: Plan) -> None:
assert plan.address == "20 Larch Way, Bromley, GB"
def test_floor_count(plan: Plan) -> None:
assert len(plan.floors) == 2
# --- Room dimensions ---
def test_first_room_name(plan: Plan) -> None:
assert plan.floors[0].rooms[0].name == "Kitchen"
def test_room_dimensions_are_floats(plan: Plan) -> None:
room = plan.floors[0].rooms[0]
assert isinstance(room.width_m, float)
assert isinstance(room.length_m, float)
assert isinstance(room.area_m2, float)
def test_room_area_rounded_to_2dp(plan: Plan) -> None:
room = plan.floors[0].rooms[0]
assert room.area_m2 == 9.39
def test_room_dimensions_parsed_from_string(plan: Plan) -> None:
room = plan.floors[0].rooms[0]
assert room.width_m == 3.19
assert room.length_m == 2.94
# --- Windows ---
def test_kitchen_has_windows(plan: Plan) -> None:
assert len(plan.floors[0].rooms[0].windows) >= 1
def test_window_fields_are_floats(plan: Plan) -> None:
window = plan.floors[0].rooms[0].windows[0]
assert isinstance(window.width_m, float)
assert isinstance(window.height_m, float)
assert isinstance(window.area_m2, float)
def test_window_area_is_width_times_height(plan: Plan) -> None:
window = plan.floors[0].rooms[0].windows[0]
assert window.area_m2 == round(window.width_m * window.height_m, 2)
def test_window_dimensions_rounded_to_2dp(plan: Plan) -> None:
window = plan.floors[0].rooms[0].windows[0]
assert window.width_m == 0.90
assert window.height_m == 1.00
assert window.area_m2 == 0.90
def test_hallway_has_no_windows(plan: Plan) -> None:
hallway = plan.floors[0].rooms[3]
assert hallway.name == "Hallway"
assert hallway.windows == []
# --- Doors ---
def test_kitchen_has_doors(plan: Plan) -> None:
assert len(plan.floors[0].rooms[0].doors) >= 1
def test_door_width_is_float(plan: Plan) -> None:
door = plan.floors[0].rooms[0].doors[0]
assert isinstance(door.width_mm, float)
def test_door_width_rounded_to_2dp(plan: Plan) -> None:
door = plan.floors[0].rooms[0].doors[0]
assert door.width_mm == 800.0
def test_door_height_is_correct(plan: Plan) -> None:
# Kitchen doorhinged has size.z = 2.04 m = 2040 mm
door = plan.floors[0].rooms[0].doors[0]
assert door.height_mm == 2040.0
# --- Ventilation ---
def test_window_with_no_custom_fields_has_no_ventilation() -> None:
wi = WallItem(
uid="test",
symbol=Symbol(id="windowcasement", name="Casement Window", valid=True),
size=Vec3(x=1.0, y=0.0, z=1.2),
position=Vec3(x=0.0, y=0.0, z=0.0),
rotation=Vec3(x=0.0, y=0.0, z=0.0),
)
window = _map_window(wi)
assert window.ventilation is None
def test_mapped_window_has_no_opening_type() -> None:
wi = WallItem(
uid="test",
symbol=Symbol(id="windowcasement", name="Casement Window", valid=True),
size=Vec3(x=1.0, y=0.0, z=1.2),
position=Vec3(x=0.0, y=0.0, z=0.0),
rotation=Vec3(x=0.0, y=0.0, z=0.0),
)
window = _map_window(wi)
assert not hasattr(window, "opening_type")
def test_kitchen_window_has_ventilation(plan: Plan) -> None:
window = plan.floors[0].rooms[0].windows[0]
assert window.ventilation is not None
assert window.ventilation.trickle_vent_area_mm2 == 1700
def test_toilet_door_has_ventilation_undercut(plan: Plan) -> None:
toilet_doors = plan.floors[0].rooms[2].doors
hinged = next(d for d in toilet_doors if d.ventilation is not None)
assert hinged.ventilation is not None
assert hinged.ventilation.undercut_mm == 70.0
def test_doorglass_is_classified_as_window(plan: Plan) -> None:
room = plan.floors[0].rooms[1]
assert any(
w.ventilation is not None and w.ventilation.opening_type == "External.Door"
for w in room.windows
)
def test_glass_door_ventilation_opening_type(plan: Plan) -> None:
room = plan.floors[0].rooms[1]
glass = next(
w for w in room.windows
if w.ventilation is not None and w.ventilation.opening_type == "External.Door"
)
assert glass.ventilation is not None
assert glass.ventilation.opening_type == "External.Door"
# --- Address unit tests ---
def test_map_address_with_street_and_number() -> None:
addr = api.Address(street_number="2", street="Laburnum Way", city="Bromley", country="GB")
assert map_address(addr) == "2 Laburnum Way, Bromley, GB"
def test_map_address_with_street_number_only() -> None:
addr = api.Address(street_number="2", city="Bromley", country="GB")
assert map_address(addr) == "2, Bromley, GB"
def test_map_address_with_street_only() -> None:
addr = api.Address(street="Laburnum Way", city="Bromley", country="GB")
assert map_address(addr) == "Laburnum Way, Bromley, GB"
def test_map_address_city_absent_is_omitted() -> None:
addr = api.Address(street_number="2", street="Laburnum Way", country="GB")
assert map_address(addr) == "2 Laburnum Way, GB"
def test_map_address_none_returns_none() -> None:
assert map_address(None) is None

View file

@ -4,16 +4,16 @@ from typing import Any
import pytest
from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
from domain.magicplan.api.response import MagicPlanPlan, PlansListResponse
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
FIXTURE_DIR = Path(__file__).parents[3] / "tests" / "magic_plan"
PLAN_ID = "72efd2e0-b2b9-48cd-b82e-41f5b3166c9a"
@pytest.fixture(scope="module")
def raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
(FIXTURE_DIR / "magicplan_api_plan_response.json").read_text()
)
return payload["data"]
@ -23,66 +23,55 @@ def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlanPlan.model_validate(raw_data)
def test_model_validate_does_not_raise(raw_data: dict[str, Any]):
# act
def test_model_validate_does_not_raise(raw_data: dict[str, Any]) -> None:
MagicPlanPlan.model_validate(raw_data)
def test_plan_id(mp: MagicPlanPlan):
# assert
def test_plan_id(mp: MagicPlanPlan) -> None:
assert mp.plan.id == PLAN_ID
def test_url_3d_alias(mp: MagicPlanPlan):
# assert
assert mp.plan.url_3d is not None
assert mp.plan.url_3d.startswith("http")
def test_floor_count(mp: MagicPlanPlan):
# assert
def test_floor_count(mp: MagicPlanPlan) -> None:
assert len(mp.plan_detail.plan.floors) == 2
def test_first_room_name(mp: MagicPlanPlan):
# assert
def test_first_room_name(mp: MagicPlanPlan) -> None:
assert mp.plan_detail.plan.floors[0].rooms[0].name == "Kitchen"
def test_room_area_is_float(mp: MagicPlanPlan):
# arrange
def test_room_area_is_float(mp: MagicPlanPlan) -> None:
room = mp.plan_detail.plan.floors[0].rooms[0]
# assert
assert isinstance(room.area, float)
def test_wall_item_symbol_id(mp: MagicPlanPlan):
# arrange
def test_wall_item_symbol_id(mp: MagicPlanPlan) -> None:
room = mp.plan_detail.plan.floors[0].rooms[0]
# assert
assert room.wall_items[0].symbol.id != ""
def test_field_value_array(mp: MagicPlanPlan):
# arrange
def test_custom_displayable_fields_parsed(mp: MagicPlanPlan) -> None:
# Kitchen windowcasement carries custom_displayable_fields in the new fixture.
room = mp.plan_detail.plan.floors[0].rooms[0]
array_field = next(f for f in room.displayable_fields if f.value.is_array)
# assert
wi = next(w for w in room.wall_items if w.symbol.id == "windowcasement")
assert len(wi.custom_displayable_fields) > 0
def test_field_value_array(mp: MagicPlanPlan) -> None:
room = mp.plan_detail.plan.floors[0].rooms[0]
wi = next(w for w in room.wall_items if w.symbol.id == "windowcasement")
array_field = next(f for f in wi.custom_displayable_fields if f.value.is_array)
assert isinstance(array_field.value.value, list)
def test_field_value_scalar(mp: MagicPlanPlan):
# arrange
def test_field_value_scalar(mp: MagicPlanPlan) -> None:
room = mp.plan_detail.plan.floors[0].rooms[0]
scalar_field = next(f for f in room.displayable_fields if not f.value.is_array)
# assert
wi = next(w for w in room.wall_items if w.symbol.id == "windowcasement")
scalar_field = next(f for f in wi.custom_displayable_fields if not f.value.is_array)
assert isinstance(scalar_field.value.value, str)
def test_extra_fields_ignored(raw_data: dict[str, Any]):
# arrange
def test_extra_fields_ignored(raw_data: dict[str, Any]) -> None:
data_with_extra = {**raw_data, "unknown_future_field": "whatever"}
# act
MagicPlanPlan.model_validate(data_with_extra)
@ -105,39 +94,31 @@ def plans_response(plans_raw_data: dict[str, Any]) -> PlansListResponse:
def test_plans_list_model_validate_does_not_raise(
plans_raw_data: dict[str, Any],
) -> None:
# act
PlansListResponse.model_validate(plans_raw_data)
def test_plans_list_count(plans_response: PlansListResponse) -> None:
# assert
assert len(plans_response.plans) == 1
def test_plans_list_first_plan_id(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.plans[0].id == "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365"
def test_plans_list_paging_page(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.paging.page == 1
def test_plans_list_paging_next_page_is_false(
plans_response: PlansListResponse,
) -> None:
# assert
assert plans_response.paging.next_page is False
def test_plans_list_paging_count(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.paging.count == 1
def test_plans_list_unknown_keys_ignored(plans_raw_data: dict[str, Any]) -> None:
# arrange
data_with_extra = {**plans_raw_data, "unknown_future_field": "whatever"}
# act
PlansListResponse.model_validate(data_with_extra)

View file

@ -6,8 +6,8 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from backend.magic_plan.magic_plan_client import MagicPlanClient
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
from domain.magicplan.api.response import MagicPlanPlan, PlanSummary
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
BASE_URL = "https://cloud.magicplan.app/api/v2"
@ -22,7 +22,7 @@ def _load_fixture(name: str) -> dict[str, Any]:
def _make_client(mock_session: MagicMock) -> MagicPlanClient:
mock_session.headers = {}
with patch(
"backend.magic_plan.magic_plan_client.requests.Session",
"infrastructure.magic_plan.magic_plan_client.requests.Session",
return_value=mock_session,
):
return MagicPlanClient(customer_id=CUSTOMER_ID, api_key=API_KEY)
@ -129,11 +129,17 @@ def test_get_plans_multi_page_fetches_all_pages(
page2_plan = {**page1_plan, "id": "page-2-plan-id"}
page1_response = MagicMock()
page1_response.json.return_value = {
"data": {"paging": {"page": 1, "next_page": True, "count": 2}, "plans": [page1_plan]}
"data": {
"paging": {"page": 1, "next_page": True, "count": 2},
"plans": [page1_plan],
}
}
page2_response = MagicMock()
page2_response.json.return_value = {
"data": {"paging": {"page": 2, "next_page": False, "count": 2}, "plans": [page2_plan]}
"data": {
"paging": {"page": 2, "next_page": False, "count": 2},
"plans": [page2_plan],
}
}
mock_session.get.side_effect = [page1_response, page2_response]
# Act
@ -154,7 +160,7 @@ def test_get_plan_calls_correct_url(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
plan_data = _load_fixture("magicplan_api_plan_response.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
@ -170,7 +176,7 @@ def test_get_plan_calls_raise_for_status(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
plan_data = _load_fixture("magicplan_api_plan_response.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
@ -185,7 +191,7 @@ def test_get_plan_returns_magic_plan(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
plan_data = _load_fixture("magicplan_api_plan_response.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
@ -194,7 +200,7 @@ def test_get_plan_returns_magic_plan(
result = client.get_plan("a7285ed1-878d-47eb-8aa6-85ef9e187516")
# Assert
assert isinstance(result, MagicPlanPlan)
assert result.plan.id == "a7285ed1-878d-47eb-8aa6-85ef9e187516"
assert result.plan.id == "72efd2e0-b2b9-48cd-b82e-41f5b3166c9a"
def test_get_plan_propagates_http_error(

View file

@ -0,0 +1,21 @@
import pytest
from infrastructure.magic_plan.config import MagicPlanConfig
_ENV = {"MAGICPLAN_CUSTOMER_ID": "cust-123", "MAGICPLAN_API_KEY": "key-abc"}
def test_from_env_constructs_config() -> None:
config = MagicPlanConfig.from_env(_ENV)
assert config.customer_id == "cust-123"
assert config.api_key == "key-abc"
def test_from_env_raises_on_missing_customer_id() -> None:
with pytest.raises(KeyError):
MagicPlanConfig.from_env({"MAGICPLAN_API_KEY": "key-abc"})
def test_from_env_raises_on_missing_api_key() -> None:
with pytest.raises(KeyError):
MagicPlanConfig.from_env({"MAGICPLAN_CUSTOMER_ID": "cust-123"})

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,39 @@
{
"data": {
"paging": {
"page": 1,
"next_page": false,
"count": 1
},
"plans": [
{
"id": "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"project_id": "269422e7-45b6-4582-b124-405053dcd967",
"name": "11, Br1 3lp",
"address": {
"street": "11 Station Road",
"street_number": null,
"postal_code": "BR1 3LP",
"city": "Bromley",
"country": "GB",
"longitude": 0.01593668,
"latitude": 51.40901033
},
"creation_date": "2026-04-28T09:35:44+00:00",
"update_date": "2026-05-05T12:53:36+00:00",
"thumbnail_url": "https://s3.amazonaws.com/prod.plans.sensopia.com/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365/plan.thumb",
"public_url": "https://cloud.magicplan.app/plan/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"cloud_url": "https://cloud.magicplan.app/projects/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"3d_url": "https://3d.magicplan.app/#embed/?key=MmFkZDJjNGRmYWRjM2Y5ZDAwMjEyZGRlY2I3NmJjOWFjOWRmMDdkNzIxZTViZDdhNTgxZDBiYWE1YTYzZTJmY%2FJNEogVfW%2FZwVfY25qc24oCKnfVxiF%2FupeeA7vwS8FECF0L9E7DUFE%2ByzEYzYaoVc%2FbtsZ%2FqZOSPopiR4OqD3zbCziU0QTydELS32cnSFOT",
"workgroup_id": "677d01685458a",
"team_id": null,
"created_by": {
"id": "b19771e9-1aad-45a5-9a41-f01a835172ea",
"firstname": null,
"lastname": null,
"email": "archie.ratcliff@domna.homes"
}
}
]
}
}

View file

@ -1,48 +1,44 @@
import json
from pathlib import Path
from typing import Optional
from unittest.mock import ANY, MagicMock, patch
import pytest
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from domain.magicplan.api.response import MagicPlanPlan, PlanSummary
from domain.magicplan.mapper import map_plan
from domain.magicplan.models import Plan
from backend.app.db.models.uploaded_file import (
FileSourceEnum,
FileTypeEnum,
UploadedFile,
)
from backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_service import MagicPlanService
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
from infrastructure.s3.s3_client import S3Client
from orchestration.magic_plan_orchestrator import MagicPlanOrchestrator
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
PLAN_ID = "72efd2e0-b2b9-48cd-b82e-41f5b3166c9a"
S3_BUCKET = "test-bucket"
@pytest.fixture(scope="module")
def domain_plan() -> Plan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response.json").read_text())
return map_plan(MagicPlanPlan.model_validate(data["data"]))
@pytest.fixture(scope="module")
def api_magic_plan() -> MagicPlanPlan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response.json").read_text())
return MagicPlanPlan.model_validate(data["data"])
@pytest.fixture(scope="module")
def plan_summary() -> PlanSummary:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response.json").read_text())
return MagicPlanPlan.model_validate(data["data"]).plan
@ -50,25 +46,43 @@ def plan_summary() -> PlanSummary:
def mock_client() -> MagicMock:
client = MagicMock(spec=MagicPlanClient)
client.get_plan_raw.return_value = (
FIXTURE_DIR / "magicplan_api_plan_response_example.json"
FIXTURE_DIR / "magicplan_api_plan_response.json"
).read_bytes()
return client
def _make_service(mock_client: MagicMock) -> MagicPlanService:
return MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
def _make_s3_client(bucket: str = S3_BUCKET) -> MagicMock:
s3 = MagicMock(spec=S3Client)
s3.bucket = bucket
return s3
def _make_service(
mock_client: MagicMock, mock_s3: Optional[MagicMock] = None
) -> MagicPlanOrchestrator:
if mock_s3 is None:
mock_s3 = _make_s3_client()
return MagicPlanOrchestrator(magic_plan_api_client=mock_client, s3_client=mock_s3)
def _make_request(
address: str = "2 Laburnum Way Bromley BR2 8BZ",
hubspot_deal_id: str = "deal-123",
uprn: str | None = None,
uprn: Optional[str] = None,
) -> MagicPlanTriggerRequest:
return MagicPlanTriggerRequest(
address=address, hubspot_deal_id=hubspot_deal_id, uprn=uprn
)
def _patch_db() -> tuple[patch, patch, patch]: # type: ignore[type-arg]
return (
patch("orchestration.magic_plan_orchestrator.PostgresConfig"),
patch("orchestration.magic_plan_orchestrator.make_engine"),
patch("orchestration.magic_plan_orchestrator.make_session"),
)
# --- no match ---
@ -94,14 +108,13 @@ def test_run_fetches_plan_with_matched_id(
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session:
service.run(_make_request())
# Assert
mock_client.get_plan_raw.assert_called_once_with(plan_summary.id)
@ -117,21 +130,20 @@ def test_run_returns_mapped_plan(
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session:
result = service.run(_make_request())
# Assert
assert isinstance(result, Plan)
assert result.uid == PLAN_ID
def test_run_calls_save_plan_with_mapped_plan(
def test_run_calls_save_with_mapped_plan(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
@ -140,18 +152,18 @@ def test_run_calls_save_plan_with_mapped_plan(
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
mock_repo = MagicMock()
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan") as mock_save, patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository",
return_value=mock_repo,
), p_config, p_engine, p_session:
service.run(_make_request())
# Assert — save_plan called with a Plan whose uid matches
call_args = mock_save.call_args
saved_plan: Plan = call_args[0][1]
# Assert — save called with a Plan whose uid matches
saved_plan: Plan = mock_repo.save.call_args[0][0]
assert saved_plan.uid == PLAN_ID
@ -164,14 +176,13 @@ def test_run_accepts_uprn_without_error(
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session:
service.run(_make_request(uprn="100023336956"))
@ -180,56 +191,51 @@ def test_run_accepts_uprn_without_error(
def test_run_uploads_to_s3_with_uprn_key(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value = [plan_summary]
mock_s3 = _make_s3_client()
request = _make_request(uprn="100023336956")
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
service = _make_service(mock_client, mock_s3)
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
) as mock_s3:
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session:
# Act
service.run(request)
# Assert
mock_s3.assert_called_once_with(
ANY,
S3_BUCKET,
mock_s3.put_object.assert_called_once_with(
f"documents/uprn/100023336956/magic_plan_{plan_summary.id}.json.gz",
ANY,
)
def test_run_uploads_to_s3_with_deal_id_key_when_uprn_absent(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
mock_s3 = _make_s3_client()
request = _make_request(hubspot_deal_id="deal-456", uprn=None)
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
service = _make_service(mock_client, mock_s3)
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
), patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
) as mock_s3:
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session:
# Act
service.run(request)
# Assert
mock_s3.assert_called_once_with(
ANY,
S3_BUCKET,
mock_s3.put_object.assert_called_once_with(
f"documents/hubspot_deal_id/deal-456/magic_plan_{plan_summary.id}.json.gz",
ANY,
)
@ -238,24 +244,22 @@ def test_run_uploads_to_s3_with_deal_id_key_when_uprn_absent(
def test_run_creates_uploaded_file_record(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
mock_s3 = _make_s3_client()
request = _make_request(hubspot_deal_id="deal-789", uprn="100023336956")
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
service = _make_service(mock_client, mock_s3)
mock_session = MagicMock()
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
) as mock_db, patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
mock_db.return_value.__enter__.return_value = mock_session
), patch(
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository"
), p_config, p_engine, p_session as mock_make_session:
mock_make_session.return_value = mock_session
# Act
service.run(request)
# Assert
@ -267,13 +271,16 @@ def test_run_creates_uploaded_file_record(
assert uploaded_file.file_source == FileSourceEnum.MAGIC_PLAN.value
assert uploaded_file.file_type == FileTypeEnum.MAGIC_PLAN_JSON.value
assert uploaded_file.s3_file_bucket == S3_BUCKET
assert uploaded_file.s3_file_key == f"documents/uprn/100023336956/magic_plan_{plan_summary.id}.json.gz"
assert (
uploaded_file.s3_file_key
== f"documents/uprn/100023336956/magic_plan_{plan_summary.id}.json.gz"
)
assert uploaded_file.s3_upload_timestamp is not None
assert uploaded_file.uprn == 100023336956
assert uploaded_file.hubspot_deal_id == "deal-789"
def test_run_passes_flushed_uploaded_file_id_to_save_plan(
def test_run_passes_flushed_uploaded_file_id_to_save(
mock_client: MagicMock,
plan_summary: PlanSummary,
) -> None:
@ -281,7 +288,7 @@ def test_run_passes_flushed_uploaded_file_id_to_save_plan(
mock_client.get_plans.return_value = [plan_summary]
service = _make_service(mock_client)
mock_session = MagicMock()
added_objects: list = []
added_objects: list[object] = []
mock_session.add.side_effect = added_objects.append
@ -291,18 +298,19 @@ def test_run_passes_flushed_uploaded_file_id_to_save_plan(
obj.id = 42
mock_session.flush.side_effect = simulate_flush
mock_repo = MagicMock()
p_config, p_engine, p_session = _patch_db()
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
"orchestration.magic_plan_orchestrator.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan") as mock_save, patch(
"backend.magic_plan.magic_plan_service.db_session"
) as mock_db, patch(
"backend.magic_plan.magic_plan_service.save_data_to_s3"
):
mock_db.return_value.__enter__.return_value = mock_session
), patch(
"orchestration.magic_plan_orchestrator.MagicPlanPostgresRepository",
return_value=mock_repo,
), p_config, p_engine, p_session as mock_make_session:
mock_make_session.return_value = mock_session
# Act
service.run(_make_request())
# Assert
assert mock_save.call_args[0][2] == 42
# Assert — save called with the flushed uploaded_file_id
assert mock_repo.save.call_args[0][1] == 42

View file

@ -0,0 +1,113 @@
from __future__ import annotations
from sqlalchemy import Engine
from sqlmodel import Session, select
from domain.magicplan.models import (
Door,
DoorVentilation,
Floor,
Plan,
Room,
Window,
WindowVentilation,
)
from infrastructure.postgres.magic_plan_tables import (
MagicPlanDoorModel,
MagicPlanDoorVentilationModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
MagicPlanWindowVentilationModel,
)
from repositories.magic_plan.magic_plan_postgres_repository import (
MagicPlanPostgresRepository,
)
def _plan() -> Plan:
window = Window(
width_m=1.2,
height_m=1.5,
area_m2=1.8,
ventilation=WindowVentilation(
opening_type="30.Hinged.Pivot.Window",
num_openings=2,
pct_openable=70,
trickle_vent_area_mm2=1700,
num_trickle_vents=2,
),
)
door = Door(width_mm=762.0, height_mm=2040.0, ventilation=DoorVentilation(undercut_mm=70.0))
room = Room(
name="Living Room",
width_m=4.0,
length_m=5.0,
area_m2=20.0,
windows=[window],
doors=[door],
)
floor = Floor(level=0, name="Ground Floor", rooms=[room])
return Plan(
uid="test-uid-abc123",
name="Test Plan",
address="1 Test Street",
postcode="TE1 1ST",
floors=[floor],
)
def test_save_writes_all_rows(db_engine: Engine) -> None:
# Arrange
plan = _plan()
# Act
with Session(db_engine) as session:
MagicPlanPostgresRepository(session).save(plan, uploaded_file_id=1)
session.commit()
# Assert — one row written to every table in the aggregate
with Session(db_engine) as session:
assert len(session.exec(select(MagicPlanPlanModel)).all()) == 1
assert len(session.exec(select(MagicPlanFloorModel)).all()) == 1
assert len(session.exec(select(MagicPlanRoomModel)).all()) == 1
assert len(session.exec(select(MagicPlanWindowModel)).all()) == 1
assert len(session.exec(select(MagicPlanDoorModel)).all()) == 1
def test_save_writes_ventilation_rows(db_engine: Engine) -> None:
# Arrange — plan with one window (with ventilation) and one door (with ventilation)
plan = _plan()
# Act
with Session(db_engine) as session:
MagicPlanPostgresRepository(session).save(plan, uploaded_file_id=1)
session.commit()
# Assert
with Session(db_engine) as session:
assert len(session.exec(select(MagicPlanWindowVentilationModel)).all()) == 1
assert len(session.exec(select(MagicPlanDoorVentilationModel)).all()) == 1
def test_save_is_idempotent(db_engine: Engine) -> None:
# Arrange
plan = _plan()
# Act — save twice with the same plan uid; second must overwrite, not append
with Session(db_engine) as session:
repo = MagicPlanPostgresRepository(session)
repo.save(plan, uploaded_file_id=1)
repo.save(plan, uploaded_file_id=1)
session.commit()
# Assert — row counts do not double
with Session(db_engine) as session:
assert len(session.exec(select(MagicPlanPlanModel)).all()) == 1
assert len(session.exec(select(MagicPlanFloorModel)).all()) == 1
assert len(session.exec(select(MagicPlanRoomModel)).all()) == 1
assert len(session.exec(select(MagicPlanWindowModel)).all()) == 1
assert len(session.exec(select(MagicPlanDoorModel)).all()) == 1
assert len(session.exec(select(MagicPlanWindowVentilationModel)).all()) == 1
assert len(session.exec(select(MagicPlanDoorVentilationModel)).all()) == 1

41
utilities/logger.py Normal file
View file

@ -0,0 +1,41 @@
import logging
from os import PathLike
from typing import Optional, Union
def setup_logger(
log_file: Optional[Union[str, PathLike[str]]] = None,
level: int = logging.INFO,
overwrite_handler: bool = False,
) -> logging.Logger:
# Create a logger and set the logging level
logger = logging.getLogger()
logger.setLevel(level)
# if logger already has handlers, just return it
if logger.hasHandlers() and not overwrite_handler:
return logger
# Define the log message format
log_format = "%(asctime)s [%(levelname)s] %(message)s"
date_format = "%Y-%m-%d %H:%M:%S"
formatter = logging.Formatter(log_format, datefmt=date_format)
# Create a file handler and set the file path and format
if log_file:
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Create a console handler and set the format
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
# Set the formatter for the handlers
console_handler.setFormatter(formatter)
# Add the handlers to the logger
logger.addHandler(console_handler)
return logger