mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge branch 'feature/claude_skills_in_devcontainer' into feature/etl_process_for_old_csv
This commit is contained in:
commit
8b6a572223
30 changed files with 1404 additions and 52 deletions
|
|
@ -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,10 +63,19 @@ 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}
|
||||
|
|
@ -86,10 +83,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
141
backend/app/db/functions/magic_plan_functions.py
Normal file
141
backend/app/db/functions/magic_plan_functions.py
Normal 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))
|
||||
0
backend/app/db/functions/tests/__init__.py
Normal file
0
backend/app/db/functions/tests/__init__.py
Normal file
41
backend/app/db/functions/tests/conftest.py
Normal file
41
backend/app/db/functions/tests/conftest.py
Normal 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()
|
||||
95
backend/app/db/functions/tests/test_magic_plan_functions.py
Normal file
95
backend/app/db/functions/tests/test_magic_plan_functions.py
Normal 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
|
||||
)
|
||||
52
backend/app/db/models/magic_plan.py
Normal file
52
backend/app/db/models/magic_plan.py
Normal 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
|
||||
134
backend/app/db/models/tests/test_magic_plan_models.py
Normal file
134
backend/app/db/models/tests/test_magic_plan_models.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
0
backend/magic_plan/__init__.py
Normal file
0
backend/magic_plan/__init__.py
Normal file
46
backend/magic_plan/address_matcher.py
Normal file
46
backend/magic_plan/address_matcher.py
Normal 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
|
||||
36
backend/magic_plan/handler.py
Normal file
36
backend/magic_plan/handler.py
Normal 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)
|
||||
24
backend/magic_plan/magic_plan_client.py
Normal file
24
backend/magic_plan/magic_plan_client.py
Normal 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"])
|
||||
42
backend/magic_plan/magic_plan_service.py
Normal file
42
backend/magic_plan/magic_plan_service.py
Normal 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
|
||||
10
backend/magic_plan/magic_plan_trigger_request.py
Normal file
10
backend/magic_plan/magic_plan_trigger_request.py
Normal 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
|
||||
39
backend/magic_plan/magicplan_api_plans_response_example.json
Normal file
39
backend/magic_plan/magicplan_api_plans_response_example.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
0
backend/magic_plan/tests/__init__.py
Normal file
0
backend/magic_plan/tests/__init__.py
Normal file
129
backend/magic_plan/tests/test_address_matcher.py
Normal file
129
backend/magic_plan/tests/test_address_matcher.py
Normal 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"
|
||||
103
backend/magic_plan/tests/test_handler.py
Normal file
103
backend/magic_plan/tests/test_handler.py
Normal 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
|
||||
174
backend/magic_plan/tests/test_magic_plan_client.py
Normal file
174
backend/magic_plan/tests/test_magic_plan_client.py
Normal 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")
|
||||
146
backend/magic_plan/tests/test_magic_plan_service.py
Normal file
146
backend/magic_plan/tests/test_magic_plan_service.py
Normal 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")
|
||||
40
backend/magic_plan/tests/test_magic_plan_trigger_request.py
Normal file
40
backend/magic_plan/tests/test_magic_plan_trigger_request.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -731,3 +731,18 @@ 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue