diff --git a/.devcontainer/backend/Dockerfile b/.devcontainer/backend/Dockerfile index a92d37f6..a8a25f27 100644 --- a/.devcontainer/backend/Dockerfile +++ b/.devcontainer/backend/Dockerfile @@ -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 diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json index a9b7352a..1c5859e5 100644 --- a/.devcontainer/backend/devcontainer.json +++ b/.devcontainer/backend/devcontainer.json @@ -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" diff --git a/.devcontainer/backend/docker-compose.yml b/.devcontainer/backend/docker-compose.yml index 757cfbe0..cf3bb2c0 100644 --- a/.devcontainer/backend/docker-compose.yml +++ b/.devcontainer/backend/docker-compose.yml @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index e72eb693..d0362fb8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/db/functions/magic_plan_functions.py b/backend/app/db/functions/magic_plan_functions.py new file mode 100644 index 00000000..9400f36f --- /dev/null +++ b/backend/app/db/functions/magic_plan_functions.py @@ -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)) diff --git a/backend/app/db/functions/tests/__init__.py b/backend/app/db/functions/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/db/functions/tests/conftest.py b/backend/app/db/functions/tests/conftest.py new file mode 100644 index 00000000..3f97e92b --- /dev/null +++ b/backend/app/db/functions/tests/conftest.py @@ -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() diff --git a/backend/app/db/functions/tests/test_magic_plan_functions.py b/backend/app/db/functions/tests/test_magic_plan_functions.py new file mode 100644 index 00000000..e58d0528 --- /dev/null +++ b/backend/app/db/functions/tests/test_magic_plan_functions.py @@ -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 + ) diff --git a/backend/app/db/models/magic_plan.py b/backend/app/db/models/magic_plan.py new file mode 100644 index 00000000..38e9de18 --- /dev/null +++ b/backend/app/db/models/magic_plan.py @@ -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 diff --git a/backend/app/db/models/tests/test_magic_plan_models.py b/backend/app/db/models/tests/test_magic_plan_models.py new file mode 100644 index 00000000..0830b184 --- /dev/null +++ b/backend/app/db/models/tests/test_magic_plan_models.py @@ -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" diff --git a/backend/engine/engine.py b/backend/engine/engine.py index f7a374e0..8b4ee821 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -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, diff --git a/backend/magic_plan/__init__.py b/backend/magic_plan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/address_matcher.py b/backend/magic_plan/address_matcher.py new file mode 100644 index 00000000..3477c535 --- /dev/null +++ b/backend/magic_plan/address_matcher.py @@ -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 diff --git a/backend/magic_plan/handler.py b/backend/magic_plan/handler.py new file mode 100644 index 00000000..a592cc6a --- /dev/null +++ b/backend/magic_plan/handler.py @@ -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) diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py new file mode 100644 index 00000000..60f70fb1 --- /dev/null +++ b/backend/magic_plan/magic_plan_client.py @@ -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"]) diff --git a/backend/magic_plan/magic_plan_service.py b/backend/magic_plan/magic_plan_service.py new file mode 100644 index 00000000..91b3cd13 --- /dev/null +++ b/backend/magic_plan/magic_plan_service.py @@ -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 diff --git a/backend/magic_plan/magic_plan_trigger_request.py b/backend/magic_plan/magic_plan_trigger_request.py new file mode 100644 index 00000000..bb0151e4 --- /dev/null +++ b/backend/magic_plan/magic_plan_trigger_request.py @@ -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 diff --git a/backend/magic_plan/magicplan_api_plans_response_example.json b/backend/magic_plan/magicplan_api_plans_response_example.json new file mode 100644 index 00000000..b8fcf1f9 --- /dev/null +++ b/backend/magic_plan/magicplan_api_plans_response_example.json @@ -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" + } + } + ] + } +} \ No newline at end of file diff --git a/backend/magic_plan/tests/__init__.py b/backend/magic_plan/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/tests/test_address_matcher.py b/backend/magic_plan/tests/test_address_matcher.py new file mode 100644 index 00000000..347a49ef --- /dev/null +++ b/backend/magic_plan/tests/test_address_matcher.py @@ -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" diff --git a/backend/magic_plan/tests/test_handler.py b/backend/magic_plan/tests/test_handler.py new file mode 100644 index 00000000..366f3ded --- /dev/null +++ b/backend/magic_plan/tests/test_handler.py @@ -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 diff --git a/backend/magic_plan/tests/test_magic_plan_client.py b/backend/magic_plan/tests/test_magic_plan_client.py new file mode 100644 index 00000000..1be1448f --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -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") diff --git a/backend/magic_plan/tests/test_magic_plan_service.py b/backend/magic_plan/tests/test_magic_plan_service.py new file mode 100644 index 00000000..8e433b87 --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_service.py @@ -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") diff --git a/backend/magic_plan/tests/test_magic_plan_trigger_request.py b/backend/magic_plan/tests/test_magic_plan_trigger_request.py new file mode 100644 index 00000000..46a20a37 --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_trigger_request.py @@ -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" diff --git a/datatypes/magicplan/api/response.py b/datatypes/magicplan/api/response.py index 8e704c65..69801128 100644 --- a/datatypes/magicplan/api/response.py +++ b/datatypes/magicplan/api/response.py @@ -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 diff --git a/datatypes/magicplan/api/tests/test_response.py b/datatypes/magicplan/api/tests/test_response.py index 663da9ca..a4d966dd 100644 --- a/datatypes/magicplan/api/tests/test_response.py +++ b/datatypes/magicplan/api/tests/test_response.py @@ -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) diff --git a/datatypes/magicplan/domain/mapper.py b/datatypes/magicplan/domain/mapper.py index fc525d5c..1804e58e 100644 --- a/datatypes/magicplan/domain/mapper.py +++ b/datatypes/magicplan/domain/mapper.py @@ -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 ") diff --git a/datatypes/magicplan/domain/tests/test_mapper.py b/datatypes/magicplan/domain/tests/test_mapper.py index 2e5bcf47..78977939 100644 --- a/datatypes/magicplan/domain/tests/test_mapper.py +++ b/datatypes/magicplan/domain/tests/test_mapper.py @@ -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): diff --git a/infrastructure/terraform/shared/main.tf b/infrastructure/terraform/shared/main.tf index 34fbfe75..050ebdc2 100644 --- a/infrastructure/terraform/shared/main.tf +++ b/infrastructure/terraform/shared/main.tf @@ -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 } \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index 761dfbed..398c5b71 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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