mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
feat(property): hydrate planning restrictions from the spatial cache
Slice 3c.5. `PropertyPostgresRepository` takes an injected `SpatialRepository` and hydrates `Property.planning_restrictions` by UPRN (bulk in `get_many`, single in `get`). A UPRN with no cached row — or a property with no UPRN — defaults to unrestricted, matching legacy `empty_spatial_df` (ADR-0020). This closes the loop: Ingestion caches the protections, Modelling reads them off the Property to gate solid-wall EWI/IWI (ADR-0019). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
af5dfdf8e2
commit
3e8304ce46
3 changed files with 110 additions and 7 deletions
|
|
@ -40,10 +40,13 @@ class PostgresUnitOfWork(UnitOfWork):
|
||||||
def __enter__(self) -> "PostgresUnitOfWork":
|
def __enter__(self) -> "PostgresUnitOfWork":
|
||||||
self._session = self._session_factory()
|
self._session = self._session_factory()
|
||||||
epc_repo = EpcPostgresRepository(self._session)
|
epc_repo = EpcPostgresRepository(self._session)
|
||||||
self.property = PropertyPostgresRepository(self._session, epc_repo)
|
spatial_repo = SpatialPostgresRepository(self._session)
|
||||||
|
self.property = PropertyPostgresRepository(
|
||||||
|
self._session, epc_repo, spatial_repo
|
||||||
|
)
|
||||||
self.epc = epc_repo
|
self.epc = epc_repo
|
||||||
self.solar = SolarPostgresRepository(self._session)
|
self.solar = SolarPostgresRepository(self._session)
|
||||||
self.spatial = SpatialPostgresRepository(self._session)
|
self.spatial = spatial_repo
|
||||||
self.property_baseline = PropertyBaselinePostgresRepository(self._session)
|
self.property_baseline = PropertyBaselinePostgresRepository(self._session)
|
||||||
self.scenario = ScenarioPostgresRepository(self._session)
|
self.scenario = ScenarioPostgresRepository(self._session)
|
||||||
self.product = ProductPostgresRepository(self._session)
|
self.product = ProductPostgresRepository(self._session)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,35 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.property.properties import Properties
|
from domain.property.properties import Properties
|
||||||
from domain.property.property import Property, PropertyIdentity
|
from domain.property.property import Property, PropertyIdentity
|
||||||
from infrastructure.postgres.property_table import PropertyRow
|
from infrastructure.postgres.property_table import PropertyRow
|
||||||
from repositories.epc.epc_repository import EpcRepository
|
from repositories.epc.epc_repository import EpcRepository
|
||||||
from repositories.property.property_repository import PropertyRepository
|
from repositories.property.property_repository import PropertyRepository
|
||||||
|
from repositories.spatial.spatial_repository import SpatialRepository
|
||||||
|
|
||||||
|
|
||||||
class PropertyPostgresRepository(PropertyRepository):
|
class PropertyPostgresRepository(PropertyRepository):
|
||||||
"""Hydrates the Property aggregate from the FE-owned ``property`` row plus the
|
"""Hydrates the Property aggregate from the FE-owned ``property`` row plus the
|
||||||
EPC slice (via an injected `EpcRepository`). Reads only from repos — no
|
EPC slice (via an injected `EpcRepository`) and the planning protections
|
||||||
external IO — so a hydrated Property is a pure function of repository state
|
(via an injected `SpatialRepository`, keyed by UPRN — ADR-0020). Reads only
|
||||||
(ADR-0003).
|
from repos — no external IO — so a hydrated Property is a pure function of
|
||||||
|
repository state (ADR-0003).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: Session, epc_repo: EpcRepository) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
epc_repo: EpcRepository,
|
||||||
|
spatial_repo: SpatialRepository,
|
||||||
|
) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
self._epc_repo = epc_repo
|
self._epc_repo = epc_repo
|
||||||
|
self._spatial_repo = spatial_repo
|
||||||
|
|
||||||
def get(self, property_id: int) -> Property:
|
def get(self, property_id: int) -> Property:
|
||||||
row = self._session.get(PropertyRow, property_id)
|
row = self._session.get(PropertyRow, property_id)
|
||||||
|
|
@ -31,9 +42,13 @@ class PropertyPostgresRepository(PropertyRepository):
|
||||||
uprn=row.uprn,
|
uprn=row.uprn,
|
||||||
landlord_property_id=row.landlord_property_id,
|
landlord_property_id=row.landlord_property_id,
|
||||||
)
|
)
|
||||||
|
restrictions: dict[int, PlanningRestrictions] = self._restrictions_for(
|
||||||
|
[row.uprn] if row.uprn is not None else []
|
||||||
|
)
|
||||||
return Property(
|
return Property(
|
||||||
identity=identity,
|
identity=identity,
|
||||||
epc=self._epc_repo.get_for_property(property_id),
|
epc=self._epc_repo.get_for_property(property_id),
|
||||||
|
planning_restrictions=_restrictions_of(row.uprn, restrictions),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_many(self, property_ids: list[int]) -> Properties:
|
def get_many(self, property_ids: list[int]) -> Properties:
|
||||||
|
|
@ -44,6 +59,9 @@ class PropertyPostgresRepository(PropertyRepository):
|
||||||
).all()
|
).all()
|
||||||
row_by_id = {row.id: row for row in rows}
|
row_by_id = {row.id: row for row in rows}
|
||||||
epcs = self._epc_repo.get_for_properties(property_ids)
|
epcs = self._epc_repo.get_for_properties(property_ids)
|
||||||
|
restrictions: dict[int, PlanningRestrictions] = self._restrictions_for(
|
||||||
|
[row.uprn for row in rows if row.uprn is not None]
|
||||||
|
)
|
||||||
items: list[Property] = []
|
items: list[Property] = []
|
||||||
for property_id in property_ids:
|
for property_id in property_ids:
|
||||||
row = row_by_id.get(property_id)
|
row = row_by_id.get(property_id)
|
||||||
|
|
@ -59,6 +77,24 @@ class PropertyPostgresRepository(PropertyRepository):
|
||||||
landlord_property_id=row.landlord_property_id,
|
landlord_property_id=row.landlord_property_id,
|
||||||
),
|
),
|
||||||
epc=epcs.get(property_id),
|
epc=epcs.get(property_id),
|
||||||
|
planning_restrictions=_restrictions_of(row.uprn, restrictions),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Properties(items)
|
return Properties(items)
|
||||||
|
|
||||||
|
def _restrictions_for(
|
||||||
|
self, uprns: list[int]
|
||||||
|
) -> dict[int, PlanningRestrictions]:
|
||||||
|
if not uprns:
|
||||||
|
return {}
|
||||||
|
return self._spatial_repo.get_for_uprns(uprns)
|
||||||
|
|
||||||
|
|
||||||
|
def _restrictions_of(
|
||||||
|
uprn: Optional[int], by_uprn: dict[int, PlanningRestrictions]
|
||||||
|
) -> PlanningRestrictions:
|
||||||
|
"""The cached protections for a UPRN, defaulting to unrestricted when the
|
||||||
|
UPRN is absent or uncached (per legacy `empty_spatial_df`; ADR-0020)."""
|
||||||
|
if uprn is None:
|
||||||
|
return PlanningRestrictions()
|
||||||
|
return by_uprn.get(uprn, PlanningRestrictions())
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ from sqlalchemy import Engine
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
from domain.geospatial.spatial_reference import SpatialReference
|
||||||
from infrastructure.postgres.property_table import PropertyRow
|
from infrastructure.postgres.property_table import PropertyRow
|
||||||
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
|
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
|
||||||
from repositories.property.property_postgres_repository import (
|
from repositories.property.property_postgres_repository import (
|
||||||
PropertyPostgresRepository,
|
PropertyPostgresRepository,
|
||||||
)
|
)
|
||||||
|
from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository
|
||||||
|
|
||||||
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
||||||
|
|
||||||
|
|
@ -38,7 +41,9 @@ def test_get_hydrates_identity_and_epc_slice(db_engine: Engine) -> None:
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
with Session(db_engine) as session:
|
with Session(db_engine) as session:
|
||||||
repo = PropertyPostgresRepository(session, EpcPostgresRepository(session))
|
repo = PropertyPostgresRepository(
|
||||||
|
session, EpcPostgresRepository(session), SpatialPostgresRepository(session)
|
||||||
|
)
|
||||||
prop = repo.get(property_id)
|
prop = repo.get(property_id)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
|
|
@ -47,3 +52,62 @@ def test_get_hydrates_identity_and_epc_slice(db_engine: Engine) -> None:
|
||||||
assert prop.epc == epc
|
assert prop.epc == epc
|
||||||
assert prop.source_path == "epc_with_overlay"
|
assert prop.source_path == "epc_with_overlay"
|
||||||
assert prop.effective_epc == epc
|
assert prop.effective_epc == epc
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_many_hydrates_planning_restrictions_from_the_spatial_cache(
|
||||||
|
db_engine: Engine,
|
||||||
|
) -> None:
|
||||||
|
# Arrange — a property whose UPRN has a cached listed-building flag.
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
row = PropertyRow(
|
||||||
|
portfolio_id=7, postcode="A0 0AA", address="1 Some Street", uprn=12345
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
property_id = row.id
|
||||||
|
assert property_id is not None
|
||||||
|
SpatialPostgresRepository(session).save(
|
||||||
|
uprn=12345,
|
||||||
|
reference=SpatialReference(
|
||||||
|
coordinates=None,
|
||||||
|
restrictions=PlanningRestrictions(is_listed=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
repo = PropertyPostgresRepository(
|
||||||
|
session, EpcPostgresRepository(session), SpatialPostgresRepository(session)
|
||||||
|
)
|
||||||
|
properties = repo.get_many([property_id])
|
||||||
|
|
||||||
|
# Assert — the protections are hydrated onto the Property (ADR-0020).
|
||||||
|
assert properties.items[0].planning_restrictions == PlanningRestrictions(
|
||||||
|
is_listed=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_many_defaults_to_unrestricted_when_uprn_has_no_spatial_row(
|
||||||
|
db_engine: Engine,
|
||||||
|
) -> None:
|
||||||
|
# Arrange — a property whose UPRN is not in the spatial cache.
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
row = PropertyRow(
|
||||||
|
portfolio_id=7, postcode="A0 0AA", address="1 Some Street", uprn=999
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
property_id = row.id
|
||||||
|
assert property_id is not None
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
repo = PropertyPostgresRepository(
|
||||||
|
session, EpcPostgresRepository(session), SpatialPostgresRepository(session)
|
||||||
|
)
|
||||||
|
properties = repo.get_many([property_id])
|
||||||
|
|
||||||
|
# Assert — an uncovered UPRN means unrestricted, not blocked (per legacy
|
||||||
|
# `empty_spatial_df`; ADR-0020).
|
||||||
|
assert properties.items[0].planning_restrictions == PlanningRestrictions()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue