mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
311d1e751a
commit
92de07efba
14 changed files with 387 additions and 0 deletions
0
domain/property/__init__.py
Normal file
0
domain/property/__init__.py
Normal file
25
domain/property/properties.py
Normal file
25
domain/property/properties.py
Normal 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)])
|
||||
73
domain/property/property.py
Normal file
73
domain/property/property.py
Normal 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
|
||||
23
domain/property/site_notes.py
Normal file
23
domain/property/site_notes.py
Normal 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
|
||||
23
infrastructure/postgres/property_table.py
Normal file
23
infrastructure/postgres/property_table.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]: ...
|
||||
|
|
|
|||
0
repositories/property/__init__.py
Normal file
0
repositories/property/__init__.py
Normal file
36
repositories/property/property_postgres_repository.py
Normal file
36
repositories/property/property_postgres_repository.py
Normal 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),
|
||||
)
|
||||
17
repositories/property/property_repository.py
Normal file
17
repositories/property/property_repository.py
Normal 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: ...
|
||||
0
tests/domain/property/__init__.py
Normal file
0
tests/domain/property/__init__.py
Normal file
127
tests/domain/property/test_property.py
Normal file
127
tests/domain/property/test_property.py
Normal 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]
|
||||
0
tests/repositories/property/__init__.py
Normal file
0
tests/repositories/property/__init__.py
Normal file
49
tests/repositories/property/test_property_repository.py
Normal file
49
tests/repositories/property/test_property_repository.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue