Merge pull request #1053 from Hestia-Homes/main

Needed for calico
This commit is contained in:
Jun-te Kim 2026-05-07 12:27:59 +01:00 committed by GitHub
commit 7ffb2b0189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 410927 additions and 2 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

View file

View file

@ -0,0 +1,280 @@
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)
class Vec3(BaseModel):
model_config = _IGNORE
x: float
y: float
z: float
class Symbol(BaseModel):
model_config = _IGNORE
id: str
name: str
description: Optional[str] = None
valid: bool
class FieldValue(BaseModel):
model_config = _IGNORE
index: int
has_value: bool
is_array: bool
value: Union[str, list[str]]
class SurveyField(BaseModel):
model_config = _IGNORE
id: str
type: int
type_as_string: str
is_required: bool
label: str
description: Optional[str] = None
list_values: list[str] = []
value: FieldValue
class ImageMapEntry(BaseModel):
model_config = _IGNORE
symbol_id: str
uid: str
owner_uid: str
type: int
coordinates: list[int]
class FormattedDimensions(BaseModel):
model_config = _IGNORE
width: Optional[str] = None
depth: Optional[str] = None
height: Optional[str] = None
class FormattedMeasures(BaseModel):
model_config = _IGNORE
width: Optional[str] = None
depth: Optional[str] = None
height: Optional[str] = None
area: Optional[str] = None
area_without_walls: Optional[str] = None
area_with_interior_walls_only: Optional[str] = None
area_with_walls: Optional[str] = None
doors_surface: Optional[str] = None
walls_surface: Optional[str] = None
walls_surface_without_openings: Optional[str] = None
windows_surface: Optional[str] = None
perimeter: Optional[str] = None
ground_perimeter: Optional[str] = None
living_area: Optional[str] = None
below_grade_living_area: Optional[str] = None
above_grade_living_area: Optional[str] = None
exterior_perimeter: Optional[str] = None
volume: Optional[str] = None
class Address(BaseModel):
model_config = _IGNORE
street: Optional[str] = None
street_number: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
country: Optional[str] = None
longitude: Optional[float] = None
latitude: Optional[float] = None
class CreatedBy(BaseModel):
model_config = _IGNORE
id: str
email: str
firstname: Optional[str] = None
lastname: Optional[str] = None
class Location(BaseModel):
model_config = _IGNORE
valid: bool
longitude: float
latitude: float
altitude: float
class ItemBase(BaseModel):
model_config = _IGNORE
uid: str
symbol: Symbol
size: Vec3
position: Vec3
rotation: Vec3
formatted: Optional[FormattedDimensions] = None
images: list[Any] = []
notes: Optional[str] = None
displayable_fields: list[SurveyField] = []
custom_displayable_fields: list[SurveyField] = []
class WallItem(ItemBase):
pass
class Furniture(ItemBase):
pass
class SymbolInstance(ItemBase):
pass
class Wall(BaseModel):
model_config = _IGNORE
uid: str
symbol: Symbol
length: float
images: list[Any] = []
notes: Optional[str] = None
displayable_fields: list[SurveyField] = []
custom_displayable_fields: list[SurveyField] = []
class Room(BaseModel):
model_config = _IGNORE
name: str
uid: str
symbol: Optional[Symbol] = None
size: Vec3
position: Vec3
rotation: Vec3
area: float
perimeter: Optional[float] = None
ground_perimeter: Optional[float] = None
area_without_walls: Optional[float] = None
area_with_interior_walls_only: Optional[float] = None
area_with_walls: Optional[float] = None
wall_count: Optional[int] = None
wall_count_with_interior_walls: Optional[int] = None
corner_count_with_interior_walls: Optional[int] = None
door_count: Optional[int] = None
window_count: Optional[int] = None
height: Optional[float] = None
volume: Optional[float] = None
width: Optional[float] = None
doors_surface: Optional[float] = None
walls_surface: Optional[float] = None
walls_surface_without_openings: Optional[float] = None
windows_surface: Optional[float] = None
dimensions: Optional[str] = None
room_type: Optional[str] = None
furniture_count: Optional[int] = None
image: Optional[str] = None
image_map: list[ImageMapEntry] = []
images: list[Any] = []
notes: Optional[str] = None
formatted: Optional[FormattedMeasures] = None
displayable_fields: list[SurveyField] = []
custom_displayable_fields: list[SurveyField] = []
wall_items: list[WallItem] = []
furnitures: list[Furniture] = []
walls: list[Wall] = []
class Floor(BaseModel):
model_config = _IGNORE
uid: str
symbol: Optional[Symbol] = None
size: Vec3
position: Vec3
rotation: Vec3
name: Optional[str] = None
area: Optional[float] = None
perimeter: Optional[float] = None
ground_perimeter: Optional[float] = None
area_without_walls: Optional[float] = None
area_with_interior_walls_only: Optional[float] = None
area_with_walls: Optional[float] = None
wall_count: Optional[int] = None
wall_count_with_interior_walls: Optional[int] = None
corner_count_with_interior_walls: Optional[int] = None
door_count: Optional[int] = None
window_count: Optional[int] = None
bathrooms_count: Optional[int] = None
bedrooms_count: Optional[int] = None
doors_surface: Optional[float] = None
floor_type: Optional[Union[int, str]] = None
furniture_count: Optional[int] = None
height: Optional[float] = None
level: Optional[int] = None
room_count: Optional[int] = None
volume: Optional[float] = None
walls_surface: Optional[float] = None
walls_surface_without_openings: Optional[float] = None
windows_surface: Optional[float] = None
image: Optional[str] = None
image_map: list[ImageMapEntry] = []
images: list[Any] = []
notes: Optional[str] = None
formatted: Optional[FormattedMeasures] = None
displayable_fields: list[SurveyField] = []
custom_displayable_fields: list[SurveyField] = []
rooms: list[Room] = []
furnitures: list[Furniture] = []
symbol_instances: list[SymbolInstance] = []
class PlanBody(BaseModel):
model_config = _IGNORE
uid: str
name: Optional[str] = None
symbol: Optional[Symbol] = None
size: Vec3
position: Vec3
rotation: Vec3
area: Optional[float] = None
location: Location
floors: list[Floor] = []
images: list[Any] = []
notes: Optional[str] = None
formatted: Optional[FormattedMeasures] = None
displayable_fields: list[SurveyField] = []
custom_displayable_fields: list[SurveyField] = []
customer: list[Any] = []
custom_attributes: list[Any] = []
class PlanDetail(BaseModel):
model_config = _IGNORE
extension_version: Optional[str] = None
wrapper_version: Optional[str] = None
document_version: Optional[str] = None
last_modification_date: Optional[Union[int, str]] = None
plan: PlanBody
class PlanSummary(BaseModel):
model_config = _IGNORE_POPULATE
id: str
project_id: Optional[str] = None
name: str
address: Optional[Address] = None
creation_date: Optional[str] = None
update_date: Optional[str] = None
thumbnail_url: Optional[str] = None
public_url: Optional[str] = None
cloud_url: Optional[str] = None
url_3d: Optional[str] = Field(default=None, alias="3d_url")
workgroup_id: Optional[str] = None
team_id: Optional[str] = None
created_by: Optional[CreatedBy] = None
class MagicPlan(BaseModel):
model_config = _IGNORE
plan: PlanSummary
plan_detail: PlanDetail

View file

@ -0,0 +1,86 @@
import json
from pathlib import Path
from typing import Any
import pytest
from datatypes.magicplan.api.response import MagicPlan
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
@pytest.fixture(scope="module")
def raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlan:
return MagicPlan.model_validate(raw_data)
def test_model_validate_does_not_raise(raw_data: dict[str, Any]):
# act
MagicPlan.model_validate(raw_data)
def test_plan_id(mp: MagicPlan):
# assert
assert mp.plan.id == PLAN_ID
def test_url_3d_alias(mp: MagicPlan):
# assert
assert mp.plan.url_3d is not None
assert mp.plan.url_3d.startswith("http")
def test_floor_count(mp: MagicPlan):
# assert
assert len(mp.plan_detail.plan.floors) == 2
def test_first_room_name(mp: MagicPlan):
# assert
assert mp.plan_detail.plan.floors[0].rooms[0].name == "Kitchen"
def test_room_area_is_float(mp: MagicPlan):
# arrange
room = mp.plan_detail.plan.floors[0].rooms[0]
# assert
assert isinstance(room.area, float)
def test_wall_item_symbol_id(mp: MagicPlan):
# 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):
# 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)
# assert
assert isinstance(array_field.value.value, list)
def test_field_value_scalar(mp: MagicPlan):
# 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)
# assert
assert isinstance(scalar_field.value.value, str)
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)

View file

View file

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

View file

@ -0,0 +1,40 @@
from dataclasses import dataclass, field
@dataclass
class Window:
width_m: float
height_m: float
area_m2: float
opening_type: str
@dataclass
class Door:
width_mm: float # TODO: should this be m or mm?
@dataclass
class Room:
name: str
width_m: float
length_m: float
area_m2: float
windows: list[Window] = field(default_factory=list[Window])
doors: list[Door] = field(default_factory=list[Door])
@dataclass
class Floor:
level: int | None
name: str | None
rooms: list[Room] = field(default_factory=list[Room])
@dataclass
class Plan:
uid: str
name: str | None
address: str | None = None
postcode: str | None = None
floors: list[Floor] = field(default_factory=list[Floor])

View file

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

View file

@ -3,6 +3,6 @@ pythonpath = .
log_cli = true
log_cli_level = INFO
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests
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
markers =
integration: mark a test as an integration test

View file

@ -758,7 +758,7 @@ class Costs:
:return:
"""
removal_cost = ROOM_HEATER_REMOVAL_COST * n_rooms
removal_cost = ROOM_HEATER_REMOVAL_COST * n_rooms + 200 # Adding a baseline £200 cost after commercial feedback
removal_labour_hours = ROOM_HEATER_REMOVAL_LABOUR_HOURS * n_rooms
vat = removal_cost * self.VAT_RATE