mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
change file name of epc client service
This commit is contained in:
parent
2c5c8337cc
commit
8635e2a1aa
6 changed files with 28 additions and 109 deletions
|
|
@ -23,7 +23,7 @@ from backend.utils.addressMatch import (
|
||||||
from datatypes.epc.domain.historic_epc_matching import (
|
from datatypes.epc.domain.historic_epc_matching import (
|
||||||
match_addresses_for_postcode,
|
match_addresses_for_postcode,
|
||||||
)
|
)
|
||||||
from backend.epc_client.client import EpcClientService
|
from backend.epc_client.epc_client_service import EpcClientService
|
||||||
from datatypes.epc.domain.historic_epc_matching import ScoredHistoricEpc
|
from datatypes.epc.domain.historic_epc_matching import ScoredHistoricEpc
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
from backend.epc_client.client import EpcClientService
|
from backend.epc_client.epc_client_service import EpcClientService
|
||||||
|
|
||||||
__all__ = ["EpcClientService"]
|
__all__ = ["EpcClientService"]
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
# Spec: https://raw.githubusercontent.com/communitiesuk/epb-data-warehouse/main/api/api.yml
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from backend.epc_client.exceptions import (
|
|
||||||
EpcApiError,
|
|
||||||
EpcNotFoundError,
|
|
||||||
EpcRateLimitError,
|
|
||||||
)
|
|
||||||
from backend.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
|
|
||||||
|
|
||||||
|
|
||||||
class EpcClientService:
|
|
||||||
BASE_URL = "https://api.get-energy-performance-data.communities.gov.uk"
|
|
||||||
|
|
||||||
def __init__(self, auth_token: str) -> None:
|
|
||||||
self._headers = {
|
|
||||||
"Authorization": f"Bearer {auth_token}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_by_certificate_number(self, cert_num: str) -> EpcPropertyData:
|
|
||||||
raw = call_with_retry(lambda: self._fetch_certificate(cert_num))
|
|
||||||
return EpcPropertyDataMapper.from_api_response(raw)
|
|
||||||
|
|
||||||
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
|
|
||||||
results = call_with_retry(lambda: self._search(uprn=uprn))
|
|
||||||
if not results:
|
|
||||||
return None
|
|
||||||
latest = max(results, key=lambda r: r.registration_date)
|
|
||||||
return self.get_by_certificate_number(latest.certificate_number)
|
|
||||||
|
|
||||||
def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]:
|
|
||||||
return call_with_retry(lambda: self._search(postcode=postcode))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fetch_certificate(self, cert_num: str) -> dict[str, Any]:
|
|
||||||
resp = httpx.get(
|
|
||||||
f"{self.BASE_URL}/api/certificate",
|
|
||||||
params={"certificate_number": cert_num},
|
|
||||||
headers=self._headers,
|
|
||||||
)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
raise EpcNotFoundError(cert_num)
|
|
||||||
if resp.status_code == 429:
|
|
||||||
raise EpcRateLimitError("Rate limited by EPC API")
|
|
||||||
if not resp.is_success:
|
|
||||||
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")
|
|
||||||
return resp.json()["data"]
|
|
||||||
|
|
||||||
def _search(
|
|
||||||
self,
|
|
||||||
postcode: Optional[str] = None,
|
|
||||||
uprn: Optional[int] = None,
|
|
||||||
) -> list[EpcSearchResult]:
|
|
||||||
params: dict[str, str | int] = {}
|
|
||||||
if postcode:
|
|
||||||
params["postcode"] = postcode
|
|
||||||
if uprn is not None:
|
|
||||||
params["uprn"] = uprn
|
|
||||||
|
|
||||||
resp = httpx.get(
|
|
||||||
f"{self.BASE_URL}/api/domestic/search",
|
|
||||||
params=params,
|
|
||||||
headers=self._headers,
|
|
||||||
)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
return []
|
|
||||||
if resp.status_code == 429:
|
|
||||||
raise EpcRateLimitError("Rate limited by EPC API")
|
|
||||||
if not resp.is_success:
|
|
||||||
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")
|
|
||||||
|
|
||||||
rows = resp.json().get("data", [])
|
|
||||||
return [self._parse_search_result(r) for r in rows]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_search_result(row: dict[str, Any]) -> EpcSearchResult:
|
|
||||||
return EpcSearchResult(
|
|
||||||
certificate_number=row["certificateNumber"],
|
|
||||||
address_line_1=row["addressLine1"],
|
|
||||||
address_line_2=row.get("addressLine2"),
|
|
||||||
address_line_3=row.get("addressLine3"),
|
|
||||||
address_line_4=row.get("addressLine4"),
|
|
||||||
postcode=row["postcode"],
|
|
||||||
post_town=row["postTown"],
|
|
||||||
uprn=row.get("uprn"),
|
|
||||||
current_energy_efficiency_band=row["currentEnergyEfficiencyBand"],
|
|
||||||
registration_date=row["registrationDate"],
|
|
||||||
)
|
|
||||||
|
|
@ -2,7 +2,7 @@ import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.epc_client.client import EpcClientService
|
from backend.epc_client.epc_client_service import EpcClientService
|
||||||
|
|
||||||
SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples")
|
SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from unittest.mock import MagicMock, patch, call
|
from unittest.mock import MagicMock, patch, call
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.epc_client.client import EpcClientService
|
from backend.epc_client.epc_client_service import EpcClientService
|
||||||
from backend.utils.epc_address_match import find_best_epc_match
|
from backend.utils.epc_address_match import find_best_epc_match
|
||||||
from datatypes.epc.search import EpcSearchResult
|
from datatypes.epc.search import EpcSearchResult
|
||||||
from backend.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError
|
from backend.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError
|
||||||
|
|
@ -22,7 +22,10 @@ def _mock_response(status_code=200, json_data=None):
|
||||||
# Test 1: get_by_certificate_number happy path
|
# Test 1: get_by_certificate_number happy path
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_get_by_certificate_number_returns_epc_property_data(epc_service, rdsap_21_0_1_cert):
|
|
||||||
|
def test_get_by_certificate_number_returns_epc_property_data(
|
||||||
|
epc_service, rdsap_21_0_1_cert
|
||||||
|
):
|
||||||
cert_response = {"data": rdsap_21_0_1_cert}
|
cert_response = {"data": rdsap_21_0_1_cert}
|
||||||
with patch("httpx.get", return_value=_mock_response(200, cert_response)):
|
with patch("httpx.get", return_value=_mock_response(200, cert_response)):
|
||||||
result = epc_service.get_by_certificate_number("CERT-001")
|
result = epc_service.get_by_certificate_number("CERT-001")
|
||||||
|
|
@ -34,6 +37,7 @@ def test_get_by_certificate_number_returns_epc_property_data(epc_service, rdsap_
|
||||||
# Test 2: get_by_certificate_number 404 → EpcNotFoundError
|
# Test 2: get_by_certificate_number 404 → EpcNotFoundError
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_get_by_certificate_number_404_raises_not_found(epc_service):
|
def test_get_by_certificate_number_404_raises_not_found(epc_service):
|
||||||
with patch("httpx.get", return_value=_mock_response(404)):
|
with patch("httpx.get", return_value=_mock_response(404)):
|
||||||
with pytest.raises(EpcNotFoundError):
|
with pytest.raises(EpcNotFoundError):
|
||||||
|
|
@ -44,7 +48,10 @@ def test_get_by_certificate_number_404_raises_not_found(epc_service):
|
||||||
# Test 3: 429 retried, succeeds on 3rd attempt
|
# Test 3: 429 retried, succeeds on 3rd attempt
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_get_by_certificate_number_retries_on_429_and_succeeds(epc_service, rdsap_21_0_1_cert):
|
|
||||||
|
def test_get_by_certificate_number_retries_on_429_and_succeeds(
|
||||||
|
epc_service, rdsap_21_0_1_cert
|
||||||
|
):
|
||||||
cert_response = {"data": rdsap_21_0_1_cert}
|
cert_response = {"data": rdsap_21_0_1_cert}
|
||||||
responses = [
|
responses = [
|
||||||
_mock_response(429),
|
_mock_response(429),
|
||||||
|
|
@ -61,6 +68,7 @@ def test_get_by_certificate_number_retries_on_429_and_succeeds(epc_service, rdsa
|
||||||
# Test 4: get_by_uprn empty search → None
|
# Test 4: get_by_uprn empty search → None
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_get_by_uprn_returns_none_when_no_results(epc_service):
|
def test_get_by_uprn_returns_none_when_no_results(epc_service):
|
||||||
with patch("httpx.get", return_value=_mock_response(200, {"data": []})):
|
with patch("httpx.get", return_value=_mock_response(200, {"data": []})):
|
||||||
result = epc_service.get_by_uprn(100023336956)
|
result = epc_service.get_by_uprn(100023336956)
|
||||||
|
|
@ -72,6 +80,7 @@ def test_get_by_uprn_returns_none_when_no_results(epc_service):
|
||||||
# Test 5: get_by_uprn multiple results → fetches latest by registration_date
|
# Test 5: get_by_uprn multiple results → fetches latest by registration_date
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_get_by_uprn_picks_most_recent_certificate(epc_service, rdsap_21_0_1_cert):
|
def test_get_by_uprn_picks_most_recent_certificate(epc_service, rdsap_21_0_1_cert):
|
||||||
search_rows = [
|
search_rows = [
|
||||||
make_search_row(cert_num="CERT-OLD", registration_date="2022-01-01"),
|
make_search_row(cert_num="CERT-OLD", registration_date="2022-01-01"),
|
||||||
|
|
@ -98,6 +107,7 @@ def test_get_by_uprn_picks_most_recent_certificate(epc_service, rdsap_21_0_1_cer
|
||||||
# Test 6: search_by_postcode returns list[EpcSearchResult]
|
# Test 6: search_by_postcode returns list[EpcSearchResult]
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_search_by_postcode_returns_results(epc_service):
|
def test_search_by_postcode_returns_results(epc_service):
|
||||||
rows = [
|
rows = [
|
||||||
make_search_row(cert_num="CERT-A", address_line_1="1 High Street"),
|
make_search_row(cert_num="CERT-A", address_line_1="1 High Street"),
|
||||||
|
|
@ -116,6 +126,7 @@ def test_search_by_postcode_returns_results(epc_service):
|
||||||
# Test 7: search_by_postcode 404 → empty list
|
# Test 7: search_by_postcode 404 → empty list
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_search_by_postcode_404_returns_empty_list(epc_service):
|
def test_search_by_postcode_404_returns_empty_list(epc_service):
|
||||||
with patch("httpx.get", return_value=_mock_response(404)):
|
with patch("httpx.get", return_value=_mock_response(404)):
|
||||||
results = epc_service.search_by_postcode("ZZ9 9ZZ")
|
results = epc_service.search_by_postcode("ZZ9 9ZZ")
|
||||||
|
|
@ -127,6 +138,7 @@ def test_search_by_postcode_404_returns_empty_list(epc_service):
|
||||||
# Tests 8-10: find_best_epc_match — real scoring, only HTTP mocked
|
# Tests 8-10: find_best_epc_match — real scoring, only HTTP mocked
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_find_best_match_clear_winner_on_first_pass(epc_service, rdsap_21_0_1_cert):
|
def test_find_best_match_clear_winner_on_first_pass(epc_service, rdsap_21_0_1_cert):
|
||||||
search_rows = [
|
search_rows = [
|
||||||
make_search_row(cert_num="CERT-WIN", address_line_1="1 High Street"),
|
make_search_row(cert_num="CERT-WIN", address_line_1="1 High Street"),
|
||||||
|
|
@ -145,7 +157,9 @@ def test_find_best_match_clear_winner_on_first_pass(epc_service, rdsap_21_0_1_ce
|
||||||
assert isinstance(result, EpcPropertyData)
|
assert isinstance(result, EpcPropertyData)
|
||||||
|
|
||||||
|
|
||||||
def test_find_best_match_resolves_on_second_pass_using_full_address(epc_service, rdsap_21_0_1_cert):
|
def test_find_best_match_resolves_on_second_pass_using_full_address(
|
||||||
|
epc_service, rdsap_21_0_1_cert
|
||||||
|
):
|
||||||
# Both candidates share address_line_1 — round 1 is ambiguous.
|
# Both candidates share address_line_1 — round 1 is ambiguous.
|
||||||
# Round 2 scores against full_address and picks the correct floor.
|
# Round 2 scores against full_address and picks the correct floor.
|
||||||
search_rows = [
|
search_rows = [
|
||||||
|
|
@ -168,7 +182,9 @@ def test_find_best_match_resolves_on_second_pass_using_full_address(epc_service,
|
||||||
return _mock_response(200, cert_response)
|
return _mock_response(200, cert_response)
|
||||||
|
|
||||||
with patch("httpx.get", side_effect=fake_get):
|
with patch("httpx.get", side_effect=fake_get):
|
||||||
result = find_best_epc_match(epc_service, "SW1A 1AA", "1 High Street Ground Floor")
|
result = find_best_epc_match(
|
||||||
|
epc_service, "SW1A 1AA", "1 High Street Ground Floor"
|
||||||
|
)
|
||||||
|
|
||||||
assert isinstance(result, EpcPropertyData)
|
assert isinstance(result, EpcPropertyData)
|
||||||
|
|
||||||
|
|
@ -177,6 +193,8 @@ def test_find_best_match_returns_none_when_no_good_match(epc_service):
|
||||||
search_rows = [make_search_row(cert_num="CERT-X", address_line_1="99 Nowhere Lane")]
|
search_rows = [make_search_row(cert_num="CERT-X", address_line_1="99 Nowhere Lane")]
|
||||||
|
|
||||||
with patch("httpx.get", return_value=_mock_response(200, {"data": search_rows})):
|
with patch("httpx.get", return_value=_mock_response(200, {"data": search_rows})):
|
||||||
result = find_best_epc_match(epc_service, "SW1A 1AA", "1 Completely Different Road")
|
result = find_best_epc_match(
|
||||||
|
epc_service, "SW1A 1AA", "1 Completely Different Road"
|
||||||
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
from datatypes.epc.search import EpcSearchResult
|
from datatypes.epc.search import EpcSearchResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from backend.epc_client.client import EpcClientService
|
from backend.epc_client.epc_client_service import EpcClientService
|
||||||
|
|
||||||
_MIN_MATCH_SCORE = 0.6
|
_MIN_MATCH_SCORE = 0.6
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue