diff --git a/domain/property/__init__.py b/domain/property/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/property/properties.py b/domain/property/properties.py new file mode 100644 index 00000000..b7a5aae5 --- /dev/null +++ b/domain/property/properties.py @@ -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)]) diff --git a/domain/property/property.py b/domain/property/property.py new file mode 100644 index 00000000..856eb3e3 --- /dev/null +++ b/domain/property/property.py @@ -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 diff --git a/domain/property/site_notes.py b/domain/property/site_notes.py new file mode 100644 index 00000000..04267735 --- /dev/null +++ b/domain/property/site_notes.py @@ -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 diff --git a/infrastructure/postgres/property_table.py b/infrastructure/postgres/property_table.py new file mode 100644 index 00000000..0b91a2ad --- /dev/null +++ b/infrastructure/postgres/property_table.py @@ -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) diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 52873dce..b0a8070c 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -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: diff --git a/repositories/epc/epc_repository.py b/repositories/epc/epc_repository.py index db479c85..fb83bdbc 100644 --- a/repositories/epc/epc_repository.py +++ b/repositories/epc/epc_repository.py @@ -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]: ... diff --git a/repositories/property/__init__.py b/repositories/property/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py new file mode 100644 index 00000000..c1b631dd --- /dev/null +++ b/repositories/property/property_postgres_repository.py @@ -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), + ) diff --git a/repositories/property/property_repository.py b/repositories/property/property_repository.py new file mode 100644 index 00000000..0a9045be --- /dev/null +++ b/repositories/property/property_repository.py @@ -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: ... diff --git a/tests/domain/property/__init__.py b/tests/domain/property/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/property/test_property.py b/tests/domain/property/test_property.py new file mode 100644 index 00000000..01d7edfd --- /dev/null +++ b/tests/domain/property/test_property.py @@ -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] diff --git a/tests/repositories/property/__init__.py b/tests/repositories/property/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/property/test_property_repository.py b/tests/repositories/property/test_property_repository.py new file mode 100644 index 00000000..2456a670 --- /dev/null +++ b/tests/repositories/property/test_property_repository.py @@ -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