mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(ingestion): relocate EpcClientService to infrastructure + SolarRepo (#1133)
Move the EpcClientService package (client + _retry + exceptions + tests) from the dying backend/ tree to infrastructure/epc_client/ as the New-EPC-API Fetcher; update the two callers (address2UPRN, a script). All 14 client tests pass. Add SolarRepository port + SolarPostgresRepository persisting Google Solar building insights as JSONB (solar_building_insights table), one row per Property. The EPC repo half of this slice already landed in #1129. pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
92de07efba
commit
caee4de2f4
17 changed files with 135 additions and 18 deletions
|
|
@ -19,7 +19,7 @@ from backend.address2UPRN.scoring import all_uprns_match, rank_address_similarit
|
|||
from datatypes.epc.domain.historic_epc_matching import (
|
||||
match_addresses_for_postcode,
|
||||
)
|
||||
from backend.epc_client.epc_client_service import EpcClientService
|
||||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||
from datatypes.epc.domain.historic_epc_matching import ScoredHistoricEpc
|
||||
|
||||
logger = setup_logger()
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from backend.epc_client.epc_client_service import EpcClientService
|
||||
|
||||
__all__ = ["EpcClientService"]
|
||||
3
infrastructure/epc_client/__init__.py
Normal file
3
infrastructure/epc_client/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||
|
||||
__all__ = ["EpcClientService"]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import time
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from backend.epc_client.exceptions import EpcRateLimitError
|
||||
from infrastructure.epc_client.exceptions import EpcRateLimitError
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
|
@ -5,12 +5,12 @@ from typing import Any, Optional
|
|||
|
||||
import httpx
|
||||
|
||||
from backend.epc_client.exceptions import (
|
||||
from infrastructure.epc_client.exceptions import (
|
||||
EpcApiError,
|
||||
EpcNotFoundError,
|
||||
EpcRateLimitError,
|
||||
)
|
||||
from backend.epc_client._retry import call_with_retry
|
||||
from infrastructure.epc_client._retry import call_with_retry
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from datatypes.epc.search import EpcSearchResult
|
||||
|
|
@ -2,7 +2,7 @@ import json
|
|||
import pathlib
|
||||
import pytest
|
||||
|
||||
from backend.epc_client.epc_client_service import EpcClientService
|
||||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||
|
||||
SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples")
|
||||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
from unittest.mock import MagicMock, patch, call
|
||||
import pytest
|
||||
|
||||
from backend.epc_client.epc_client_service import EpcClientService
|
||||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||
from datatypes.epc.search import EpcSearchResult
|
||||
from backend.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError
|
||||
from infrastructure.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from backend.epc_client.tests.conftest import make_search_row
|
||||
from infrastructure.epc_client.tests.conftest import make_search_row
|
||||
|
||||
|
||||
def _mock_response(status_code=200, json_data=None, headers=None):
|
||||
|
|
@ -78,7 +78,7 @@ def test_429_retry_after_header_drives_sleep_duration(
|
|||
_mock_response(200, cert_response),
|
||||
]
|
||||
with patch("httpx.get", side_effect=responses), patch(
|
||||
"backend.epc_client._retry.time.sleep"
|
||||
"infrastructure.epc_client._retry.time.sleep"
|
||||
) as mock_sleep:
|
||||
epc_service.get_by_certificate_number("CERT-001")
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ def test_429_without_retry_after_uses_exponential_backoff(
|
|||
_mock_response(200, cert_response),
|
||||
]
|
||||
with patch("httpx.get", side_effect=responses), patch(
|
||||
"backend.epc_client._retry.time.sleep"
|
||||
"infrastructure.epc_client._retry.time.sleep"
|
||||
) as mock_sleep:
|
||||
epc_service.get_by_certificate_number("CERT-001")
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ def test_429_malformed_retry_after_falls_back_to_backoff(
|
|||
_mock_response(200, cert_response),
|
||||
]
|
||||
with patch("httpx.get", side_effect=responses), patch(
|
||||
"backend.epc_client._retry.time.sleep"
|
||||
"infrastructure.epc_client._retry.time.sleep"
|
||||
) as mock_sleep:
|
||||
epc_service.get_by_certificate_number("CERT-001")
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ def test_429_retry_after_capped_by_max_backoff(epc_service, rdsap_21_0_1_cert):
|
|||
_mock_response(200, cert_response),
|
||||
]
|
||||
with patch("httpx.get", side_effect=responses), patch(
|
||||
"backend.epc_client._retry.time.sleep"
|
||||
"infrastructure.epc_client._retry.time.sleep"
|
||||
) as mock_sleep:
|
||||
epc_service.get_by_certificate_number("CERT-001")
|
||||
|
||||
22
infrastructure/postgres/solar_table.py
Normal file
22
infrastructure/postgres/solar_table.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class SolarBuildingInsightsRow(SQLModel, table=True):
|
||||
"""Persisted Google Solar `buildingInsights` response for one Property.
|
||||
|
||||
Stored as JSONB — the raw fetched insights are retained whole so the
|
||||
structured projection a future SolarPotential type needs can be derived
|
||||
without re-fetching. One row per Property.
|
||||
"""
|
||||
|
||||
__tablename__: ClassVar[str] = "solar_building_insights" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
property_id: int = Field(index=True, unique=True)
|
||||
insights: dict[str, Any] = Field(sa_column=Column(JSONB, nullable=False))
|
||||
0
repositories/solar/__init__.py
Normal file
0
repositories/solar/__init__.py
Normal file
35
repositories/solar/solar_postgres_repository.py
Normal file
35
repositories/solar/solar_postgres_repository.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from infrastructure.postgres.solar_table import SolarBuildingInsightsRow
|
||||
from repositories.solar.solar_repository import SolarRepository
|
||||
|
||||
|
||||
class SolarPostgresRepository(SolarRepository):
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def save(self, property_id: int, insights: dict[str, Any]) -> None:
|
||||
existing = self._session.exec(
|
||||
select(SolarBuildingInsightsRow).where(
|
||||
SolarBuildingInsightsRow.property_id == property_id
|
||||
)
|
||||
).first()
|
||||
if existing is None:
|
||||
self._session.add(
|
||||
SolarBuildingInsightsRow(property_id=property_id, insights=insights)
|
||||
)
|
||||
else:
|
||||
existing.insights = insights
|
||||
self._session.add(existing)
|
||||
|
||||
def get(self, property_id: int) -> Optional[dict[str, Any]]:
|
||||
row = self._session.exec(
|
||||
select(SolarBuildingInsightsRow).where(
|
||||
SolarBuildingInsightsRow.property_id == property_id
|
||||
)
|
||||
).first()
|
||||
return row.insights if row is not None else None
|
||||
19
repositories/solar/solar_repository.py
Normal file
19
repositories/solar/solar_repository.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class SolarRepository(ABC):
|
||||
"""Persists and loads a Property's Google Solar building insights.
|
||||
|
||||
Thin save/get over the raw fetched insights (a future SolarPotential domain
|
||||
type will derive its fields from these). Written by Ingestion, read by
|
||||
Baseline/Modelling — never re-fetched downstream (ADR-0003).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, property_id: int, insights: dict[str, Any]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(self, property_id: int) -> Optional[dict[str, Any]]: ...
|
||||
|
|
@ -18,9 +18,9 @@ from dotenv import load_dotenv
|
|||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend.epc_client._retry import call_with_retry
|
||||
from backend.epc_client.epc_client_service import EpcClientService
|
||||
from backend.epc_client.exceptions import (
|
||||
from infrastructure.epc_client._retry import call_with_retry
|
||||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||
from infrastructure.epc_client.exceptions import (
|
||||
EpcApiError,
|
||||
EpcNotFoundError,
|
||||
EpcRateLimitError,
|
||||
|
|
|
|||
0
tests/repositories/solar/__init__.py
Normal file
0
tests/repositories/solar/__init__.py
Normal file
41
tests/repositories/solar/test_solar_repository.py
Normal file
41
tests/repositories/solar/test_solar_repository.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""SolarRepo round-trips Google Solar building insights for a Property."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session
|
||||
|
||||
from repositories.solar.solar_postgres_repository import SolarPostgresRepository
|
||||
|
||||
|
||||
def test_building_insights_round_trip(db_engine: Engine) -> None:
|
||||
# Arrange
|
||||
insights: dict[str, Any] = {
|
||||
"name": "buildings/ChIJ",
|
||||
"solarPotential": {
|
||||
"maxArrayPanelsCount": 42,
|
||||
"panelCapacityWatts": 250.0,
|
||||
"roofSegmentStats": [{"pitchDegrees": 30.0, "azimuthDegrees": 180.0}],
|
||||
},
|
||||
}
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
SolarPostgresRepository(session).save(property_id=5, insights=insights)
|
||||
session.commit()
|
||||
with Session(db_engine) as session:
|
||||
reloaded = SolarPostgresRepository(session).get(5)
|
||||
|
||||
# Assert
|
||||
assert reloaded == insights
|
||||
|
||||
|
||||
def test_get_returns_none_when_no_insights_stored(db_engine: Engine) -> None:
|
||||
# Arrange / Act
|
||||
with Session(db_engine) as session:
|
||||
reloaded = SolarPostgresRepository(session).get(999)
|
||||
|
||||
# Assert
|
||||
assert reloaded is None
|
||||
Loading…
Add table
Reference in a new issue