feat(property): Property aggregate + PropertyRepository (#1132)

Add the Ara modelling aggregate root (ADR-0002): domain/property/ with
PropertyIdentity, SiteNotes, Property, Properties. Property.source_path
implements the two disjoint source paths + Recency Tie-Break (ADR-0001;
survey wins on an equal date); effective_epc resolves to the surveyed data
(Site Notes path) or the public EPC (epc_with_overlay path — Landlord
Overrides overlay is a later slice). Pure dataclasses, no infrastructure imports.

PropertyRepository port + PropertyPostgresRepository hydrate the aggregate
whole from a defensive view of the FE-owned 'property' table (identity columns)
plus the EPC slice via EpcRepository.get_for_property. Reads only from repos
(ADR-0003). 8 domain + 1 hydration test; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 19:39:54 +00:00
parent 311d1e751a
commit 92de07efba
14 changed files with 387 additions and 0 deletions

View file

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from domain.property.property import Property
@dataclass
class Properties:
"""A first-class collection of Property objects — the unit of bulk operation
in services (CONTEXT.md: Properties). Services take and return `Properties`
rather than bare lists so batch operations read clearly.
"""
items: list[Property]
def __iter__(self) -> Iterator[Property]:
return iter(self.items)
def __len__(self) -> int:
return len(self.items)
def filter(self, predicate: Callable[[Property], bool]) -> "Properties":
return Properties([p for p in self.items if predicate(p)])

View file

@ -0,0 +1,73 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.property.site_notes import SiteNotes
SourcePath = Literal["site_notes", "epc_with_overlay"]
@dataclass(frozen=True)
class PropertyIdentity:
"""Identifies a single Property within a portfolio.
Keyed by `(portfolio_id, uprn)` or `(portfolio_id, landlord_property_id)`
a UPRN is permanent but each portfolio gets its own Property against it
(CONTEXT.md: UPRN).
"""
portfolio_id: int
postcode: str
address: str
uprn: Optional[int] = None
landlord_property_id: Optional[str] = None
@dataclass
class Property:
"""The Ara modelling aggregate root for a single dwelling (ADR-0002).
Holds identity plus the source data the pipeline reasons about. Enrichments
(geospatial, solar) and modelling outputs (baseline performance, plans) are
added by later slices this is the minimal-and-growing shape for First Run.
"""
identity: PropertyIdentity
epc: Optional[EpcPropertyData] = None
site_notes: Optional[SiteNotes] = None
@property
def source_path(self) -> SourcePath:
"""Which of the two disjoint source paths models this Property (ADR-0001).
Site Notes alone, or the public EPC (with Landlord Overrides, once that
slice lands). When both exist the newer wins (Recency Tie-Break); on an
equal date the survey wins, as it reflects on-site observation.
"""
if self.site_notes is not None and self.epc is not None:
epc_date = self.epc.registration_date or self.epc.inspection_date
if self.site_notes.surveyed_at >= epc_date:
return "site_notes"
return "epc_with_overlay"
if self.site_notes is not None:
return "site_notes"
if self.epc is not None:
return "epc_with_overlay"
raise ValueError(
"Property has neither Site Notes nor an EPC; no source path to model from"
)
@property
def effective_epc(self) -> EpcPropertyData:
"""The EpcPropertyData the modelling pipeline scores against.
Path 1: the Site Notes' surveyed data. Path 2: the public EPC (Landlord
Overrides overlay is a later slice returned as-is for now).
"""
if self.source_path == "site_notes":
assert self.site_notes is not None
return self.site_notes.to_epc_property_data()
assert self.epc is not None
return self.epc

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from datatypes.epc.domain.epc_property_data import EpcPropertyData
@dataclass
class SiteNotes:
"""A Domna survey of a single Property (CONTEXT.md: Site Notes).
Committed by the domain to being full-coverage it carries every EPC field
the modelling pipeline needs, expressed as an `EpcPropertyData`. When present
(and not older than the public EPC) it is the complete source of truth for
the Property; the public EPC is then irrelevant (ADR-0001).
"""
surveyed_at: date
epc: EpcPropertyData
def to_epc_property_data(self) -> EpcPropertyData:
return self.epc

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from typing import ClassVar, Optional
from sqlmodel import Field, SQLModel
class PropertyRow(SQLModel, table=True):
"""Defensive view of the FE-owned ``property`` table.
The schema and migrations for ``property`` are owned by the front-end
Next.js repo; this declares only the identity columns the modelling backend
reads/writes, so FE-owned migrations to other columns don't ripple into us.
"""
__tablename__: ClassVar[str] = "property" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
portfolio_id: int
postcode: str
address: str
uprn: Optional[int] = Field(default=None)
landlord_property_id: Optional[str] = Field(default=None)

View file

@ -134,6 +134,16 @@ class EpcPostgresRepository(EpcRepository):
)
return epc_property_id
def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]:
row = self._session.exec(
select(EpcPropertyModel)
.where(EpcPropertyModel.property_id == property_id)
.order_by(EpcPropertyModel.id) # type: ignore[arg-type]
).first()
if row is None or row.id is None:
return None
return self.get(row.id)
def get(self, epc_property_id: int) -> EpcPropertyData:
p = self._session.get(EpcPropertyModel, epc_property_id)
if p is None:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
@ -24,3 +25,6 @@ class EpcRepository(ABC):
@abstractmethod
def get(self, epc_property_id: int) -> EpcPropertyData: ...
@abstractmethod
def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]: ...

View file

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from sqlmodel import Session
from domain.property.property import Property, PropertyIdentity
from infrastructure.postgres.property_table import PropertyRow
from repositories.epc.epc_repository import EpcRepository
from repositories.property.property_repository import PropertyRepository
class PropertyPostgresRepository(PropertyRepository):
"""Hydrates the Property aggregate from the FE-owned ``property`` row plus the
EPC slice (via an injected `EpcRepository`). Reads only 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:
self._session = session
self._epc_repo = epc_repo
def get(self, property_id: int) -> Property:
row = self._session.get(PropertyRow, property_id)
if row is None:
raise ValueError(f"property {property_id} not found")
identity = PropertyIdentity(
portfolio_id=row.portfolio_id,
postcode=row.postcode,
address=row.address,
uprn=row.uprn,
landlord_property_id=row.landlord_property_id,
)
return Property(
identity=identity,
epc=self._epc_repo.get_for_property(property_id),
)

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from domain.property.property import Property
class PropertyRepository(ABC):
"""Loads and saves the Property aggregate.
Composes the aggregate whole from the FE-owned ``property`` identity row plus
its source-data slices (EPC today; Site Notes / enrichments as later slices
land). Aggregates load whole never half a Property (ADR-0002).
"""
@abstractmethod
def get(self, property_id: int) -> Property: ...

View file

View file

@ -0,0 +1,127 @@
"""Property aggregate — source-path precedence and Effective EPC resolution.
The two disjoint source paths (ADR-0001): a Property is modelled either from its
Site Notes alone, or from the public EPC (with Landlord Overrides, once that slice
lands). When both exist, the newer wins (Recency Tie-Break).
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from typing import Any
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.property.properties import Properties
from domain.property.property import Property, PropertyIdentity
from domain.property.site_notes import SiteNotes
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
def _epc(inspection: str = "2023-12-01") -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _identity() -> PropertyIdentity:
return PropertyIdentity(
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=12345
)
def test_source_path_is_epc_with_overlay_when_only_epc_present() -> None:
# Arrange
prop = Property(identity=_identity(), epc=_epc())
# Act
path = prop.source_path
# Assert
assert path == "epc_with_overlay"
def test_source_path_is_site_notes_when_only_site_notes_present() -> None:
# Arrange
prop = Property(
identity=_identity(),
site_notes=SiteNotes(surveyed_at=date(2024, 6, 1), epc=_epc()),
)
# Act
path = prop.source_path
# Assert
assert path == "site_notes"
def test_recency_tie_break_newer_site_notes_win_over_older_epc() -> None:
# Arrange — EPC inspected 2023-12-01; survey is newer
prop = Property(
identity=_identity(),
epc=_epc(),
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=_epc()),
)
# Act / Assert
assert prop.source_path == "site_notes"
def test_recency_tie_break_older_site_notes_lose_to_newer_epc() -> None:
# Arrange — survey predates the EPC's inspection date
prop = Property(
identity=_identity(),
epc=_epc(),
site_notes=SiteNotes(surveyed_at=date(2020, 1, 1), epc=_epc()),
)
# Act / Assert
assert prop.source_path == "epc_with_overlay"
def test_effective_epc_follows_the_selected_source_path() -> None:
# Arrange
survey_epc = _epc()
public_epc = _epc()
site_notes_property = Property(
identity=_identity(),
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=survey_epc),
)
epc_property = Property(identity=_identity(), epc=public_epc)
# Act / Assert
assert site_notes_property.effective_epc is survey_epc
assert epc_property.effective_epc is public_epc
def test_property_with_no_source_raises() -> None:
# Arrange
prop = Property(identity=_identity())
# Act / Assert
try:
_ = prop.source_path
except ValueError:
pass
else: # pragma: no cover
raise AssertionError("expected ValueError when no source is present")
def test_properties_collection_iterates_and_filters() -> None:
# Arrange
with_epc = Property(identity=_identity(), epc=_epc())
without = Property(identity=_identity())
properties = Properties([with_epc, without])
# Act
with_source = properties.filter(lambda p: p.epc is not None)
# Assert
assert len(properties) == 2
assert list(properties) == [with_epc, without]
assert len(with_source) == 1
assert list(with_source) == [with_epc]

View file

View file

@ -0,0 +1,49 @@
"""PropertyRepository hydrates the aggregate whole from the property row + EPC slice."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from infrastructure.postgres.property_table import PropertyRow
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
from repositories.property.property_postgres_repository import (
PropertyPostgresRepository,
)
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
def test_get_hydrates_identity_and_epc_slice(db_engine: Engine) -> None:
# Arrange
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
epc = EpcPropertyDataMapper.from_api_response(raw)
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
EpcPostgresRepository(session).save(epc, property_id=property_id)
session.commit()
# Act
with Session(db_engine) as session:
repo = PropertyPostgresRepository(session, EpcPostgresRepository(session))
prop = repo.get(property_id)
# Assert
assert prop.identity.portfolio_id == 7
assert prop.identity.uprn == 12345
assert prop.epc == epc
assert prop.source_path == "epc_with_overlay"
assert prop.effective_epc == epc