Merge branch 'feature/claude_skills_in_devcontainer' into feature/etl_process_for_old_csv

This commit is contained in:
Jun-te Kim 2026-05-08 13:09:19 +00:00
commit 8b6a572223
30 changed files with 1404 additions and 52 deletions

View file

@ -18,15 +18,6 @@ RUN curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-li
| tar -xz -C /opt \
&& ln -s /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
# # 2) Build and install libpostal from source
# RUN git clone --depth 1 https://github.com/openvenues/libpostal /tmp/libpostal \
# && cd /tmp/libpostal \
# && ./bootstrap.sh \
# && ./configure --datadir=/usr/local/share/libpostal \
# && make -j"$(nproc)" \
# && make install \
# && ldconfig \
# && rm -rf /tmp/libpostal
# 3) Create the user and grant sudo privileges
RUN groupadd -g ${USER_GID} ${USER} \
@ -34,10 +25,7 @@ RUN groupadd -g ${USER_GID} ${USER} \
&& echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \
&& chmod 0440 /etc/sudoers.d/${USER}
# # 4) Python deps - if you want to run assest list
# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# ADD asset_list/requirements.txt requirements.txt
# RUN pip install -r requirements.txt
#
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
@ -75,21 +63,27 @@ RUN wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key
RUN apt update
RUN apt install -y postgresql-14
# Install Node.js + backlog.md
# Install Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g backlog.md \
&& rm -rf /var/lib/apt/lists/*
# GitHub CLI — used by the postCreate skill installer to authenticate against
# private Hestia-Homes repos via the host's mounted ~/.config/gh.
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt update && apt install -y gh \
&& rm -rf /var/lib/apt/lists/*
USER ${USER}
# Bootstrap LazyVim starter config
RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \
&& rm -rf /home/${USER}/.config/nvim/.git
# Install Claude
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& export PATH="/home/${USER}/.local/bin:${PATH}" \
&& claude plugin marketplace add JuliusBrussee/caveman \
&& claude plugin install caveman@caveman
# Install Claude Code CLI (skills are installed via postCreate from Hestia-Homes/agentic-toolkit)
RUN curl -fsSL https://claude.ai/install.sh | bash
ENV PATH="/home/vscode/.local/bin:${PATH}"
USER root

View file

@ -4,6 +4,14 @@
"service": "model-backend",
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/model",
// Host preflight: ensure GitHub auth exists before we try to build.
// Either ~/.config/gh (from `gh auth login`) or a GITHUB_TOKEN env var.
"initializeCommand": "test -d \"$HOME/.config/gh\" || test -n \"$GITHUB_TOKEN\" || { echo >&2 'error: no GitHub auth found. Run `gh auth login && gh auth setup-git` on the host, or export GITHUB_TOKEN, then retry.'; exit 1; }",
// Install Domna's curated skill set (pinned to 0.0.5) into this workspace.
// `gh repo clone` handles private-repo auth using the mounted host ~/.config/gh.
"postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.5 --depth 1 && bash /tmp/agentic-toolkit/setup.sh",
"postStartCommand": "bash .devcontainer/backend/post-install.sh",
"mounts": [
"source=${localEnv:HOME},target=/workspaces/home,type=bind",
@ -44,12 +52,8 @@
"containerEnv": {
"PYTHONFLAGS": "-Xfrozen_modules=off"
},
"forwardPorts": [6421, 8000],
"forwardPorts": [8000],
"portsAttributes": {
"6421": {
"label": "Backlog.md",
"onAutoForward": "notify"
},
"8000": {
"label": "FastAPI",
"onAutoForward": "notify"

View file

@ -14,8 +14,13 @@ services:
volumes:
- ../../:/workspaces/model
- ~/.gitconfig:/home/vscode/.gitconfig:ro
# GitHub CLI auth from host (created by `gh auth login`). Used by the
# postCreate skill installer to clone private Hestia-Homes repos.
- ~/.config/gh:/home/vscode/.config/gh:ro
environment:
- SSH_AUTH_SOCK=${SSH_AUTH_SOCK:-}
# Fallback HTTPS auth if ~/.config/gh isn't present on the host.
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
networks:
- backend-net
- shared-dev

View file

@ -45,6 +45,8 @@ class Settings(BaseSettings):
# Third parties
EPC_AUTH_TOKEN: str = "changeme"
GOOGLE_SOLAR_API_KEY: str = "changeme"
MAGICPLAN_CUSTOMER_ID: str = "changeme"
MAGICPLAN_API_KEY: str = "changeme"
# Database settings
DB_HOST: str = "changeme"

View file

@ -0,0 +1,141 @@
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) -> None:
plan_id: int = _upsert_plan(session, plan)
_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) -> int:
stmt = (
pg_insert(MagicPlanPlanModel)
.values(
magic_plan_uid=plan.uid,
name=plan.name,
address=plan.address,
postcode=plan.postcode,
)
.on_conflict_do_update(
index_elements=["magic_plan_uid"],
set_={
"name": plan.name,
"address": plan.address,
"postcode": plan.postcode,
},
)
.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

@ -0,0 +1,41 @@
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

@ -0,0 +1,95 @@
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)
# 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)
# 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)
# 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)
# 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)
# 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)
save_plan(db_session, domain_plan)
# 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
)

View file

@ -0,0 +1,52 @@
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
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

@ -0,0 +1,134 @@
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
# --- MagicPlanPlan ---
def test_plan_table_name() -> None:
assert MagicPlanPlanModel.__tablename__ == "magic_plan_plan"
def test_plan_has_magic_plan_uid_column() -> None:
assert "magic_plan_uid" in MagicPlanPlanModel.__table__.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
)
def test_plan_instantiation() -> None:
plan = MagicPlanPlanModel(
magic_plan_uid="uid-123", name="Test", address="1 High St", postcode="SW1A 1AA"
)
assert plan.magic_plan_uid == "uid-123"
assert plan.name == "Test"
assert plan.postcode == "SW1A 1AA"
# --- MagicPlanFloor ---
def test_floor_table_name() -> None:
assert MagicPlanFloorModel.__tablename__ == "magic_plan_floor"
def test_floor_fk_column_name() -> None:
assert "magic_plan_plan_id" in MagicPlanFloorModel.__table__.columns
def test_floor_has_level() -> None:
floor = MagicPlanFloorModel(magic_plan_plan_id=1, level=0)
assert floor.level == 0
# --- MagicPlanRoom ---
def test_room_table_name() -> None:
assert MagicPlanRoomModel.__tablename__ == "magic_plan_room"
def test_room_fk_column_name() -> None:
assert "magic_plan_floor_id" in MagicPlanRoomModel.__table__.columns
def test_room_has_measurement_columns() -> None:
cols = MagicPlanRoomModel.__table__.columns
assert "width_m" in cols
assert "length_m" in cols
assert "area_m2" in cols
def test_room_instantiation() -> None:
room = MagicPlanRoomModel(
magic_plan_floor_id=1, name="Kitchen", width_m=2.67, length_m=2.98, area_m2=7.95
)
assert room.name == "Kitchen"
assert room.width_m == 2.67
# --- MagicPlanWindow ---
def test_window_table_name() -> None:
assert MagicPlanWindowModel.__tablename__ == "magic_plan_window"
def test_window_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanWindowModel.__table__.columns
def test_window_has_measurement_columns() -> None:
cols = MagicPlanWindowModel.__table__.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:
window = MagicPlanWindowModel(
magic_plan_room_id=1,
width_m=1.4,
height_m=1.2,
area_m2=1.68,
opening_type="casement",
)
assert window.opening_type == "casement"
# --- MagicPlanDoor ---
def test_door_table_name() -> None:
assert MagicPlanDoorModel.__tablename__ == "magic_plan_door"
def test_door_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanDoorModel.__table__.columns
def test_door_has_width_mm_and_type() -> None:
cols = MagicPlanDoorModel.__table__.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
assert door.type == "hinged"

View file

@ -656,6 +656,15 @@ async def model_engine(body: PlanTriggerRequest):
# address_metadata=addr Switched off to remove injecting landlord inputs
)
# Warning! The EPC API is broken and we are getting missing data for local authority and
# constituency. We're going to add some verbose handling here but there may be problems
if prepared_epc.local_authority is None:
# Fill
prepared_epc.local_authority = ""
if prepared_epc.constituency is None:
prepared_epc.constituency = ""
input_properties.append(
Property(
id=property_id,

View file

View file

@ -0,0 +1,46 @@
import re
from typing import Optional
from datatypes.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)
def _extract_postcode(address: str) -> str | None:
match = _UK_POSTCODE_RE.search(address)
if match is None:
return None
return match.group().replace(" ", "").upper()
def _normalize_postcode(postcode: str) -> str:
return postcode.replace(" ", "").upper()
def find_matching_plan(plans: list[PlanSummary], address: str) -> Optional[PlanSummary]:
postcode = _extract_postcode(address)
if postcode is None:
return None
address_lower = address.lower()
for plan in plans:
if plan.address is None:
continue
plan_postcode = plan.address.postal_code
if plan_postcode is None:
continue
if _normalize_postcode(plan_postcode) != postcode:
continue
street_parts = [
p for p in [plan.address.street_number, plan.address.street] if p
]
plan_street = " ".join(street_parts).lower()
if plan_street and plan_street in address_lower:
return plan
return None

View file

@ -0,0 +1,36 @@
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.utils.subtasks import task_handler
from utils.logger import setup_logger
logger = setup_logger()
@task_handler()
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,
)
plan: Plan = MagicPlanService(client).run(payload.address, payload.uprn)
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"}',
"messageId": "local-test",
}
]
}
handler(event, None)

View file

@ -0,0 +1,24 @@
import requests
from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
_BASE_URL = "https://cloud.magicplan.app/api/v2"
class MagicPlanClient:
def __init__(self, customer_id: str, api_key: str) -> None:
self._api_key = api_key
self._session = requests.Session()
self._session.headers.update({"customer": customer_id})
def get_plans(self) -> PlansListResponse:
r = self._session.get(f"{_BASE_URL}/plans", params={"key": self._api_key})
r.raise_for_status()
return PlansListResponse.model_validate(r.json()["data"])
def get_plan(self, plan_id: str) -> MagicPlanPlan:
r = self._session.get(
f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}
)
r.raise_for_status()
return MagicPlanPlan.model_validate(r.json()["data"])

View file

@ -0,0 +1,42 @@
from typing import Optional
from datatypes.magicplan.api.response import (
MagicPlanPlan,
PlanSummary,
PlansListResponse,
)
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from backend.app.db.connection import db_session
from backend.app.db.functions.magic_plan_functions import save_plan
from backend.magic_plan.address_matcher import find_matching_plan
from backend.magic_plan.magic_plan_client import MagicPlanClient
from utils.logger import setup_logger
logger = setup_logger()
class MagicPlanService:
def __init__(self, client: MagicPlanClient) -> None:
self._client = client
def run(self, address: str, uprn: Optional[str] = None) -> Plan:
if uprn is not None:
logger.info("MagicPlanService.run uprn=%s", uprn)
plans_response: PlansListResponse = self._client.get_plans()
matched: Optional[PlanSummary] = find_matching_plan(
plans_response.plans, address
) # TODO: use address2UPRN instead? or create AddressMatch domain class
if matched is None:
raise ValueError(f"No MagicPlan found for address: {address!r}")
magic_plan: MagicPlanPlan = self._client.get_plan(matched.id)
plan: Plan = map_plan(magic_plan)
with db_session() as session:
save_plan(session, plan)
return plan

View file

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class MagicPlanTriggerRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
address: str
uprn: Optional[str] = None

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

View file

@ -0,0 +1,129 @@
from datatypes.magicplan.api.response import PlanSummary
from backend.magic_plan.address_matcher import find_matching_plan, _extract_postcode
def _make_plan(
plan_id: str,
street: str | None = None,
street_number: str | None = None,
postal_code: str | None = None,
) -> PlanSummary:
return PlanSummary.model_validate(
{
"id": plan_id,
"name": f"Plan {plan_id}",
"address": {
"street": street,
"street_number": street_number,
"postal_code": postal_code,
},
}
)
# --- _extract_postcode ---
def test_extract_postcode_standard_format() -> None:
assert _extract_postcode("2 Laburnum Way Bromley BR2 8BZ") == "BR28BZ"
def test_extract_postcode_no_space_in_postcode() -> None:
assert _extract_postcode("123 High St London SW1A1AA") == "SW1A1AA"
def test_extract_postcode_lowercase_input() -> None:
assert _extract_postcode("2 laburnum way br2 8bz") == "BR28BZ"
def test_extract_postcode_none_when_absent() -> None:
assert _extract_postcode("123 High Street London") is None
def test_extract_postcode_none_for_empty_string() -> None:
assert _extract_postcode("") is None
# --- find_matching_plan ---
PLAN_A = _make_plan(
"plan-a", street="Laburnum Way", street_number="2", postal_code="BR2 8BZ"
)
PLAN_B = _make_plan(
"plan-b", street="Station Road", street_number="11", postal_code="BR1 3LP"
)
def test_find_matching_plan_returns_match() -> None:
# Arrange
plans = [PLAN_A, PLAN_B]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is not None
assert result.id == "plan-a"
def test_find_matching_plan_postcode_mismatch_returns_none() -> None:
# Arrange
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley SW1A 1AA")
# Assert
assert result is None
def test_find_matching_plan_street_mismatch_returns_none() -> None:
# Arrange
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "99 Other Road Bromley BR2 8BZ")
# Assert
assert result is None
def test_find_matching_plan_empty_list_returns_none() -> None:
# Act
result = find_matching_plan([], "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is None
def test_find_matching_plan_postcode_with_no_space_in_address() -> None:
# Arrange - address has postcode without internal space
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley BR28BZ")
# Assert
assert result is not None
assert result.id == "plan-a"
def test_find_matching_plan_plan_postcode_with_no_space() -> None:
# Arrange - plan has postcode without space
plan = _make_plan(
"plan-c", street="Laburnum Way", street_number="2", postal_code="BR28BZ"
)
# Act
result = find_matching_plan([plan], "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is not None
assert result.id == "plan-c"
def test_find_matching_plan_no_postcode_in_address_returns_none() -> None:
# Act
result = find_matching_plan([PLAN_A], "2 Laburnum Way Bromley")
# Assert
assert result is None
def test_find_matching_plan_second_plan_matches() -> None:
# Arrange
plans = [PLAN_A, PLAN_B]
# Act
result = find_matching_plan(plans, "11 Station Road Bromley BR1 3LP")
# Assert
assert result is not None
assert result.id == "plan-b"

View file

@ -0,0 +1,103 @@
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}
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}
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_with(ADDRESS, None)
def test_handler_passes_uprn_to_service(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "uprn": "100023336956"}
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_with(ADDRESS, "100023336956")
def test_handler_returns_plan_uid(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS}
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

@ -0,0 +1,174 @@
import json
from pathlib import Path
from typing import Any
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, PlansListResponse
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
BASE_URL = "https://cloud.magicplan.app/api/v2"
CUSTOMER_ID = "test-customer"
API_KEY = "test-key"
def _load_fixture(name: str) -> dict[str, Any]:
return json.loads((FIXTURE_DIR / name).read_text())
def _make_client(mock_session: MagicMock) -> MagicPlanClient:
with patch(
"backend.magic_plan.magic_plan_client.requests.Session",
return_value=mock_session,
):
return MagicPlanClient(customer_id=CUSTOMER_ID, api_key=API_KEY)
@pytest.fixture()
def mock_session() -> MagicMock:
return MagicMock()
@pytest.fixture()
def client(mock_session: MagicMock) -> MagicPlanClient:
return _make_client(mock_session)
# --- constructor ---
def test_customer_header_set_on_session(mock_session: MagicMock) -> None:
# Act
_make_client(mock_session)
# Assert
mock_session.headers.update.assert_called_once_with({"customer": CUSTOMER_ID})
# --- get_plans ---
def test_get_plans_calls_correct_url(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
client.get_plans()
# Assert
mock_session.get.assert_called_once_with(
f"{BASE_URL}/plans", params={"key": API_KEY}
)
def test_get_plans_calls_raise_for_status(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
client.get_plans()
# Assert
mock_session.get.return_value.raise_for_status.assert_called_once()
def test_get_plans_returns_plans_list_response(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
result = client.get_plans()
# Assert
assert isinstance(result, PlansListResponse)
assert len(result.plans) == 1
def test_get_plans_propagates_http_error(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError(
"404"
)
# Act / Assert
with pytest.raises(requests.HTTPError):
client.get_plans()
# --- get_plan ---
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"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
plan_id = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
# Act
client.get_plan(plan_id)
# Assert
mock_session.get.assert_called_once_with(
f"{BASE_URL}/plans/{plan_id}", params={"key": API_KEY}
)
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"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
# Act
client.get_plan("a7285ed1-878d-47eb-8aa6-85ef9e187516")
# Assert
mock_session.get.return_value.raise_for_status.assert_called_once()
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"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
# Act
result = client.get_plan("a7285ed1-878d-47eb-8aa6-85ef9e187516")
# Assert
assert isinstance(result, MagicPlanPlan)
assert result.plan.id == "a7285ed1-878d-47eb-8aa6-85ef9e187516"
def test_get_plan_propagates_http_error(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError(
"500"
)
# Act / Assert
with pytest.raises(requests.HTTPError):
client.get_plan("some-id")

View file

@ -0,0 +1,146 @@
import json
from pathlib import Path
from unittest.mock import 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 backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_service import MagicPlanService
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
@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"]))
@pytest.fixture(scope="module")
def api_magic_plan() -> MagicPlanPlan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.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()
)
return MagicPlanPlan.model_validate(data["data"]).plan
@pytest.fixture()
def mock_client() -> MagicMock:
return MagicMock(spec=MagicPlanClient)
def _make_service(mock_client: MagicMock) -> MagicPlanService:
return MagicPlanService(client=mock_client)
# --- no match ---
def test_run_raises_when_no_plan_found(mock_client: MagicMock) -> None:
# Arrange
mock_client.get_plans.return_value.plans = []
service = _make_service(mock_client)
# Act / Assert
with pytest.raises(ValueError, match="No MagicPlan found"):
service.run("99 Nowhere Road London SW1A 1AA")
# --- match found ---
def test_run_fetches_plan_with_matched_id(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
domain_plan: Plan,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.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"
):
service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert
mock_client.get_plan.assert_called_once_with(plan_summary.id)
def test_run_returns_mapped_plan(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
domain_plan: Plan,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.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"
):
result = service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert isinstance(result, Plan)
assert result.uid == PLAN_ID
def test_run_calls_save_plan_with_mapped_plan(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.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"
):
service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert — save_plan called with a Plan whose uid matches
call_args = mock_save.call_args
saved_plan: Plan = call_args[0][1]
assert saved_plan.uid == PLAN_ID
def test_run_accepts_uprn_without_error(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.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"
):
service.run("2 Laburnum Way Bromley BR2 8BZ", uprn="100023336956")

View file

@ -0,0 +1,40 @@
import pytest
from pydantic import ValidationError
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
def test_valid_payload_with_address_only() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.address == "123 High St London SW1A 1AA"
assert req.uprn is None
def test_valid_payload_with_uprn() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA", "uprn": "100023336956"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.uprn == "100023336956"
def test_missing_address_raises() -> None:
# Arrange
payload = {"uprn": "100023336956"}
# Act / Assert
with pytest.raises(ValidationError):
MagicPlanTriggerRequest.model_validate(payload)
def test_extra_fields_ignored() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA", "unknown_field": "whatever"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.address == "123 High St London SW1A 1AA"

View file

@ -2,7 +2,6 @@ from typing import Any, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
_IGNORE = ConfigDict(extra="ignore")
_IGNORE_POPULATE = ConfigDict(extra="ignore", populate_by_name=True)
@ -274,7 +273,20 @@ class PlanSummary(BaseModel):
created_by: Optional[CreatedBy] = None
class MagicPlan(BaseModel):
class Paging(BaseModel):
model_config = _IGNORE
page: int
next_page: bool
count: int
class PlansListResponse(BaseModel):
model_config = _IGNORE
paging: Paging
plans: list[PlanSummary] = []
class MagicPlanPlan(BaseModel):
model_config = _IGNORE
plan: PlanSummary
plan_detail: PlanDetail

View file

@ -4,7 +4,7 @@ from typing import Any
import pytest
from datatypes.magicplan.api.response import MagicPlan
from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
@ -19,51 +19,51 @@ def raw_data() -> dict[str, Any]:
@pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlan:
return MagicPlan.model_validate(raw_data)
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
MagicPlan.model_validate(raw_data)
MagicPlanPlan.model_validate(raw_data)
def test_plan_id(mp: MagicPlan):
def test_plan_id(mp: MagicPlanPlan):
# assert
assert mp.plan.id == PLAN_ID
def test_url_3d_alias(mp: MagicPlan):
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: MagicPlan):
def test_floor_count(mp: MagicPlanPlan):
# assert
assert len(mp.plan_detail.plan.floors) == 2
def test_first_room_name(mp: MagicPlan):
def test_first_room_name(mp: MagicPlanPlan):
# assert
assert mp.plan_detail.plan.floors[0].rooms[0].name == "Kitchen"
def test_room_area_is_float(mp: MagicPlan):
def test_room_area_is_float(mp: MagicPlanPlan):
# arrange
room = mp.plan_detail.plan.floors[0].rooms[0]
# assert
assert isinstance(room.area, float)
def test_wall_item_symbol_id(mp: MagicPlan):
def test_wall_item_symbol_id(mp: MagicPlanPlan):
# arrange
room = mp.plan_detail.plan.floors[0].rooms[0]
# assert
assert room.wall_items[0].symbol.id != ""
def test_field_value_array(mp: MagicPlan):
def test_field_value_array(mp: MagicPlanPlan):
# arrange
room = mp.plan_detail.plan.floors[0].rooms[0]
array_field = next(f for f in room.displayable_fields if f.value.is_array)
@ -71,7 +71,7 @@ def test_field_value_array(mp: MagicPlan):
assert isinstance(array_field.value.value, list)
def test_field_value_scalar(mp: MagicPlan):
def test_field_value_scalar(mp: MagicPlanPlan):
# arrange
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)
@ -83,4 +83,61 @@ def test_extra_fields_ignored(raw_data: dict[str, Any]):
# arrange
data_with_extra = {**raw_data, "unknown_future_field": "whatever"}
# act
MagicPlan.model_validate(data_with_extra)
MagicPlanPlan.model_validate(data_with_extra)
# --- PlansListResponse ---
@pytest.fixture(scope="module")
def plans_raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plans_response_example.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def plans_response(plans_raw_data: dict[str, Any]) -> PlansListResponse:
return PlansListResponse.model_validate(plans_raw_data)
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

@ -1,9 +1,11 @@
from typing import Optional
import datatypes.magicplan.api.response as api
from datatypes.magicplan.api.response import MagicPlan
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.models import Plan, Floor, Room, Window, Door
def map_plan(mp: MagicPlan) -> Plan:
def map_plan(mp: MagicPlanPlan) -> Plan:
return Plan(
uid=mp.plan.id,
name=mp.plan.name,
@ -13,7 +15,7 @@ def map_plan(mp: MagicPlan) -> Plan:
)
def _map_address(addr: api.Address | None) -> str | None:
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
@ -43,7 +45,7 @@ def _map_room(r: api.Room) -> Room:
)
def _parse_dimensions(dimensions: str | None) -> tuple[float, float]:
def _parse_dimensions(dimensions: Optional[str]) -> tuple[float, float]:
if not dimensions:
return 0.0, 0.0
parts = dimensions.split(" x ")

View file

@ -4,7 +4,7 @@ from typing import Any
import pytest
from datatypes.magicplan.api.response import MagicPlan
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
@ -22,12 +22,12 @@ def raw_data() -> dict[str, Any]:
@pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlan:
return MagicPlan.model_validate(raw_data)
def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlanPlan.model_validate(raw_data)
@pytest.fixture(scope="module")
def plan(mp: MagicPlan) -> Plan:
def plan(mp: MagicPlanPlan) -> Plan:
return map_plan(mp)
@ -119,7 +119,7 @@ def raw_data_2() -> dict[str, Any]:
@pytest.fixture(scope="module")
def plan2(raw_data_2: dict[str, Any]) -> Plan:
return map_plan(MagicPlan.model_validate(raw_data_2))
return map_plan(MagicPlanPlan.model_validate(raw_data_2))
def test_plan2_uid(plan2: Plan):
@ -201,7 +201,7 @@ def plan3() -> Plan:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_3.json").read_text()
)
return map_plan(MagicPlan.model_validate(payload["data"]))
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan3_address_uses_street_number_and_omits_city(plan3: Plan):
@ -220,7 +220,7 @@ def plan4() -> Plan:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_4.json").read_text()
)
return map_plan(MagicPlan.model_validate(payload["data"]))
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan4_address_uses_street_number_when_street_absent(plan4: Plan):

View file

@ -730,4 +730,19 @@ module "hubspot_etl_s3_read_and_write" {
output "hubspot_etl_s3_read_and_write_arn" {
value = module.hubspot_etl_s3_read_and_write.policy_arn
}
################################################
# MagicPlan Client Lambda
################################################
module "magic_plan_client_bucket" {
source = "../modules/tf_state_bucket"
bucket_name = "magic-plan-client-terraform-state"
}
module "magic_plan_client_registry" {
source = "../modules/container_registry"
name = "magic-plan"
stage = var.stage
}

View file

@ -3,6 +3,6 @@ pythonpath = .
log_cli = true
log_cli_level = INFO
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests backend/app/db/functions/tests
markers =
integration: mark a test as an integration test