property-shared 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/routes.py +12 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/epc.py +90 -0
- app/api/v1/health.py +8 -0
- app/api/v1/meta.py +29 -0
- app/api/v1/planning.py +249 -0
- app/api/v1/ppd.py +178 -0
- app/api/v1/report.py +66 -0
- app/api/v1/rightmove.py +75 -0
- app/clients/http.py +8 -0
- app/core/__init__.py +1 -0
- app/core/config.py +37 -0
- app/core/logging.py +12 -0
- app/main.py +40 -0
- app/py.typed +0 -0
- app/schemas/__init__.py +1 -0
- app/schemas/epc.py +21 -0
- app/schemas/ppd.py +41 -0
- app/schemas/report.py +17 -0
- app/schemas/rightmove.py +36 -0
- app/services/__init__.py +1 -0
- app/services/epc_service.py +52 -0
- app/services/rightmove_service.py +95 -0
- app/tasks/__init__.py +1 -0
- app/templates/report.html +370 -0
- app/utils/polite.py +34 -0
- app/web/__init__.py +2 -0
- app/web/routes.py +16 -0
- app/web/templates/index.html +98 -0
- property_cli/__init__.py +2 -0
- property_cli/main.py +945 -0
- property_cli/py.typed +0 -0
- property_core/__init__.py +31 -0
- property_core/enrichment.py +143 -0
- property_core/epc_client.py +266 -0
- property_core/models/__init__.py +40 -0
- property_core/models/epc.py +52 -0
- property_core/models/ppd.py +95 -0
- property_core/models/report.py +125 -0
- property_core/models/rightmove.py +89 -0
- property_core/planning_councils.json +1249 -0
- property_core/planning_diagnostics.py +295 -0
- property_core/planning_scraper.py +1134 -0
- property_core/planning_service.py +279 -0
- property_core/postcode_client.py +69 -0
- property_core/ppd_client.py +545 -0
- property_core/ppd_service.py +473 -0
- property_core/py.typed +0 -0
- property_core/rental_service.py +94 -0
- property_core/report_service.py +523 -0
- property_core/rightmove_location.py +124 -0
- property_core/rightmove_scraper.py +537 -0
- property_shared-0.1.0.dist-info/METADATA +136 -0
- property_shared-0.1.0.dist-info/RECORD +58 -0
- property_shared-0.1.0.dist-info/WHEEL +4 -0
- property_shared-0.1.0.dist-info/entry_points.txt +4 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared property service package."""
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routers for the shared service."""
|
app/api/routes.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from .v1 import epc, health, meta, planning, ppd, report, rightmove
|
|
4
|
+
|
|
5
|
+
api_router = APIRouter(prefix="/v1")
|
|
6
|
+
api_router.include_router(health.router)
|
|
7
|
+
api_router.include_router(ppd.router)
|
|
8
|
+
api_router.include_router(epc.router)
|
|
9
|
+
api_router.include_router(rightmove.router)
|
|
10
|
+
api_router.include_router(planning.router)
|
|
11
|
+
api_router.include_router(report.router)
|
|
12
|
+
api_router.include_router(meta.router)
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""v1 API routers."""
|
app/api/v1/epc.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""EPC API endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
9
|
+
|
|
10
|
+
from app.schemas.epc import EPCRecordResponse
|
|
11
|
+
from app.services.epc_service import EPCService
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/epc", tags=["epc"])
|
|
14
|
+
service = EPCService()
|
|
15
|
+
|
|
16
|
+
# UK postcode regex for parsing combined address strings
|
|
17
|
+
UK_POSTCODE_RE = re.compile(
|
|
18
|
+
r"([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\s*$",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_address_query(q: str) -> tuple[Optional[str], Optional[str]]:
|
|
24
|
+
"""Parse a combined address string into (postcode, address).
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
"10 Downing Street, SW1A 2AA" -> ("SW1A 2AA", "10 Downing Street")
|
|
28
|
+
"SW1A 2AA" -> ("SW1A 2AA", None)
|
|
29
|
+
"""
|
|
30
|
+
q = q.strip()
|
|
31
|
+
match = UK_POSTCODE_RE.search(q)
|
|
32
|
+
if match:
|
|
33
|
+
postcode = match.group(1).upper()
|
|
34
|
+
# Everything before the postcode is the address
|
|
35
|
+
address_part = q[: match.start()].strip().rstrip(",").strip()
|
|
36
|
+
return (postcode, address_part if address_part else None)
|
|
37
|
+
return (None, None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/certificate/{certificate_hash}", response_model=EPCRecordResponse)
|
|
41
|
+
async def get_certificate(
|
|
42
|
+
certificate_hash: str,
|
|
43
|
+
include_raw: bool = Query(False, description="Include raw EPC API JSON"),
|
|
44
|
+
) -> EPCRecordResponse:
|
|
45
|
+
"""Get EPC certificate by lmk-key (certificate hash)."""
|
|
46
|
+
if not service.is_configured():
|
|
47
|
+
raise HTTPException(status_code=501, detail="EPC client not configured")
|
|
48
|
+
|
|
49
|
+
result = await service.get_certificate(
|
|
50
|
+
certificate_hash=certificate_hash, include_raw=include_raw
|
|
51
|
+
)
|
|
52
|
+
if result is None:
|
|
53
|
+
raise HTTPException(status_code=404, detail="No EPC certificate found")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/search", response_model=EPCRecordResponse)
|
|
58
|
+
async def search(
|
|
59
|
+
postcode: Optional[str] = Query(None, min_length=2),
|
|
60
|
+
address: Optional[str] = None,
|
|
61
|
+
q: Optional[str] = Query(None, description="Combined address query, e.g. '10 Downing Street, SW1A 2AA'"),
|
|
62
|
+
include_raw: bool = Query(False, description="Include raw EPC API JSON"),
|
|
63
|
+
) -> EPCRecordResponse:
|
|
64
|
+
"""Search for an EPC certificate by postcode (optional address match).
|
|
65
|
+
|
|
66
|
+
Supports two modes:
|
|
67
|
+
1. Explicit: postcode=SW1A+2AA&address=10+Downing+Street
|
|
68
|
+
2. Combined: q=10+Downing+Street,+SW1A+2AA (postcode parsed from end)
|
|
69
|
+
"""
|
|
70
|
+
if not service.is_configured():
|
|
71
|
+
raise HTTPException(status_code=501, detail="EPC client not configured")
|
|
72
|
+
|
|
73
|
+
# Parse combined query if provided
|
|
74
|
+
if q:
|
|
75
|
+
parsed_postcode, parsed_address = _parse_address_query(q)
|
|
76
|
+
if not parsed_postcode:
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=422,
|
|
79
|
+
detail="Could not parse postcode from query. Use format: '10 Downing Street, SW1A 2AA'",
|
|
80
|
+
)
|
|
81
|
+
postcode = parsed_postcode
|
|
82
|
+
address = parsed_address or address
|
|
83
|
+
|
|
84
|
+
if not postcode:
|
|
85
|
+
raise HTTPException(status_code=422, detail="postcode or q parameter required")
|
|
86
|
+
|
|
87
|
+
result = await service.search(postcode=postcode, address=address, include_raw=include_raw)
|
|
88
|
+
if result is None:
|
|
89
|
+
raise HTTPException(status_code=404, detail="No EPC certificate found")
|
|
90
|
+
return result
|
app/api/v1/health.py
ADDED
app/api/v1/meta.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Meta endpoints for service introspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from app.core.config import get_settings
|
|
8
|
+
from app.services.epc_service import EPCService
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/meta", tags=["meta"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/integrations", summary="Integration configuration status")
|
|
14
|
+
async def integrations() -> dict[str, object]:
|
|
15
|
+
"""Return which integrations are configured/enabled.
|
|
16
|
+
|
|
17
|
+
Intended for AI agents / clients to self-check capabilities before calling.
|
|
18
|
+
"""
|
|
19
|
+
settings = get_settings()
|
|
20
|
+
epc = EPCService()
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
"environment": settings.environment,
|
|
24
|
+
"integrations": {
|
|
25
|
+
"ppd": {"available": True, "configured": True},
|
|
26
|
+
"rightmove": {"available": True, "configured": True},
|
|
27
|
+
"epc": {"available": True, "configured": epc.is_configured()},
|
|
28
|
+
},
|
|
29
|
+
}
|
app/api/v1/planning.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Planning API endpoints: scrape UK council planning portals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel, HttpUrl
|
|
9
|
+
|
|
10
|
+
from property_core.planning_service import PlanningService
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/planning", tags=["planning"])
|
|
13
|
+
service = PlanningService()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlanningScraperRequest(BaseModel):
|
|
17
|
+
"""Request to scrape a planning application."""
|
|
18
|
+
url: str
|
|
19
|
+
save_screenshots: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlanningScraperResponse(BaseModel):
|
|
23
|
+
"""Response from planning scraper."""
|
|
24
|
+
url: str
|
|
25
|
+
council_system: str
|
|
26
|
+
screenshots_captured: int
|
|
27
|
+
data: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanningScrapeError(BaseModel):
|
|
31
|
+
"""Error response."""
|
|
32
|
+
url: str
|
|
33
|
+
error: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/scrape", response_model=PlanningScraperResponse)
|
|
37
|
+
def scrape_application(
|
|
38
|
+
request: PlanningScraperRequest,
|
|
39
|
+
) -> PlanningScraperResponse:
|
|
40
|
+
"""
|
|
41
|
+
Scrape a single planning application from any UK council portal.
|
|
42
|
+
|
|
43
|
+
Uses vision AI to extract structured data from screenshots.
|
|
44
|
+
Supports Idox, Northgate, Ocella, Arcus, and generic systems.
|
|
45
|
+
|
|
46
|
+
Note: This is a sync endpoint that may take 20-40 seconds.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
from property_core.planning_scraper import scrape_planning_application
|
|
50
|
+
except ImportError as e:
|
|
51
|
+
raise HTTPException(
|
|
52
|
+
status_code=501,
|
|
53
|
+
detail=f"Planning scraper not available: {e}. Install playwright and openai.",
|
|
54
|
+
) from e
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
result = scrape_planning_application(
|
|
58
|
+
url=request.url,
|
|
59
|
+
save_screenshots=request.save_screenshots,
|
|
60
|
+
)
|
|
61
|
+
return PlanningScraperResponse(**result)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
raise HTTPException(status_code=502, detail=f"Scrape failed: {e}") from e
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ProbeRequest(BaseModel):
|
|
67
|
+
"""Request for connectivity probe."""
|
|
68
|
+
url: str
|
|
69
|
+
timeout_ms: int = 30000
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ProbeResponse(BaseModel):
|
|
73
|
+
"""Diagnostic probe response."""
|
|
74
|
+
url: str
|
|
75
|
+
success: bool
|
|
76
|
+
page_title: Optional[str]
|
|
77
|
+
status_code: Optional[int]
|
|
78
|
+
load_time_ms: Optional[int]
|
|
79
|
+
screenshot_base64: Optional[str]
|
|
80
|
+
html_snippet: Optional[str]
|
|
81
|
+
blocking_indicators: list[str]
|
|
82
|
+
error: Optional[str]
|
|
83
|
+
proxy_used: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/probe", response_model=ProbeResponse)
|
|
87
|
+
def probe_url(request: ProbeRequest) -> ProbeResponse:
|
|
88
|
+
"""
|
|
89
|
+
Quick connectivity probe to diagnose access issues.
|
|
90
|
+
|
|
91
|
+
Returns screenshot, HTML snippet, and blocking indicators.
|
|
92
|
+
Use this to diagnose why scraping might be failing (IP blocks, captchas, etc).
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
from property_core.planning_scraper import probe_connectivity
|
|
96
|
+
except ImportError as e:
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=501,
|
|
99
|
+
detail=f"Probe not available: {e}",
|
|
100
|
+
) from e
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = probe_connectivity(url=request.url, timeout_ms=request.timeout_ms)
|
|
104
|
+
return ProbeResponse(**result)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise HTTPException(status_code=502, detail=f"Probe failed: {e}") from e
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.get("/search")
|
|
110
|
+
def search_by_postcode(
|
|
111
|
+
postcode: str = Query(..., min_length=2, description="UK postcode"),
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""Search for planning applications by postcode.
|
|
114
|
+
|
|
115
|
+
Looks up the council for this postcode and returns search URLs.
|
|
116
|
+
For Idox councils (most common), provides a direct search URL.
|
|
117
|
+
For other systems, provides the search page and instructions.
|
|
118
|
+
|
|
119
|
+
Note: Actual scraping of search results requires UK residential IP.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
return service.search(postcode)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise HTTPException(status_code=502, detail=f"Planning search failed: {e}") from e
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.get("/council-for-postcode")
|
|
128
|
+
def council_for_postcode(
|
|
129
|
+
postcode: str = Query(..., min_length=2, description="UK postcode"),
|
|
130
|
+
include_raw: bool = Query(False, description="Include full postcodes.io response"),
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Look up the planning council for a UK postcode.
|
|
133
|
+
|
|
134
|
+
Uses postcodes.io to identify the local authority, then matches to our
|
|
135
|
+
councils database. Returns council info if found, otherwise returns
|
|
136
|
+
local authority info for reference.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
return service.council_for_postcode(postcode, include_raw=include_raw)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
raise HTTPException(status_code=502, detail=f"Postcode lookup failed: {e}") from e
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.get("/councils")
|
|
145
|
+
def list_councils() -> dict[str, Any]:
|
|
146
|
+
"""List verified UK council planning portals."""
|
|
147
|
+
return service.list_councils()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/council/{code}")
|
|
151
|
+
def get_council(code: str) -> dict[str, Any]:
|
|
152
|
+
"""Get details for a specific council by code."""
|
|
153
|
+
council = service.get_council(code)
|
|
154
|
+
if not council:
|
|
155
|
+
raise HTTPException(status_code=404, detail=f"Council '{code}' not found")
|
|
156
|
+
return council
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SearchResultsRequest(BaseModel):
|
|
160
|
+
"""Request to search for planning applications."""
|
|
161
|
+
postcode: str
|
|
162
|
+
portal_url: Optional[str] = None
|
|
163
|
+
system: Optional[str] = None
|
|
164
|
+
max_results: int = 10
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PlanningApplication(BaseModel):
|
|
168
|
+
"""Single planning application result."""
|
|
169
|
+
reference: Optional[str] = None
|
|
170
|
+
address: Optional[str] = None
|
|
171
|
+
description: Optional[str] = None
|
|
172
|
+
status: Optional[str] = None
|
|
173
|
+
link: Optional[str] = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SearchResultsResponse(BaseModel):
|
|
177
|
+
"""Response with planning search results."""
|
|
178
|
+
postcode: str
|
|
179
|
+
council_name: Optional[str] = None
|
|
180
|
+
system: Optional[str] = None
|
|
181
|
+
portal_url: str
|
|
182
|
+
results: list[PlanningApplication]
|
|
183
|
+
count: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.post("/search-results", response_model=SearchResultsResponse)
|
|
187
|
+
def search_results(request: SearchResultsRequest) -> SearchResultsResponse:
|
|
188
|
+
"""
|
|
189
|
+
Search for planning applications by postcode.
|
|
190
|
+
|
|
191
|
+
Uses vision-guided browser automation to fill council search forms
|
|
192
|
+
and extract results. Takes 30-60 seconds.
|
|
193
|
+
|
|
194
|
+
Requires: Playwright, OpenAI API key, UK residential IP (or proxy).
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
from property_core.planning_scraper import search_planning_by_postcode
|
|
198
|
+
except ImportError as e:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=501,
|
|
201
|
+
detail=f"Planning search not available: {e}. Install playwright and openai.",
|
|
202
|
+
) from e
|
|
203
|
+
|
|
204
|
+
# Resolve portal_url from postcode if not provided
|
|
205
|
+
portal_url = request.portal_url
|
|
206
|
+
system = request.system
|
|
207
|
+
council_name = None
|
|
208
|
+
|
|
209
|
+
if not portal_url:
|
|
210
|
+
search_data = service.search(request.postcode)
|
|
211
|
+
if not search_data.get("council_found"):
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=404,
|
|
214
|
+
detail=f"No council found for postcode '{request.postcode}'",
|
|
215
|
+
)
|
|
216
|
+
council = search_data["council"]
|
|
217
|
+
council_name = council.get("name")
|
|
218
|
+
system = system or council.get("system")
|
|
219
|
+
urls = search_data.get("search_urls", {})
|
|
220
|
+
portal_url = urls.get("direct_search") or urls.get("search_page")
|
|
221
|
+
# For Idox, use simple search form (not weeklyList)
|
|
222
|
+
if system == "idox" and portal_url:
|
|
223
|
+
if "weeklyList" in portal_url or "action=simple" not in portal_url:
|
|
224
|
+
base = portal_url.split("/search.do")[0] if "/search.do" in portal_url else portal_url.rstrip("/")
|
|
225
|
+
portal_url = f"{base}/search.do?action=simple"
|
|
226
|
+
if not portal_url:
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=404,
|
|
229
|
+
detail="No search URL available for this council",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
results = search_planning_by_postcode(
|
|
234
|
+
portal_url=portal_url,
|
|
235
|
+
postcode=request.postcode,
|
|
236
|
+
max_results=request.max_results,
|
|
237
|
+
system=system,
|
|
238
|
+
)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
raise HTTPException(status_code=502, detail=f"Search failed: {e}") from e
|
|
241
|
+
|
|
242
|
+
return SearchResultsResponse(
|
|
243
|
+
postcode=request.postcode,
|
|
244
|
+
council_name=council_name,
|
|
245
|
+
system=system,
|
|
246
|
+
portal_url=portal_url,
|
|
247
|
+
results=[PlanningApplication(**app) for app in results],
|
|
248
|
+
count=len(results),
|
|
249
|
+
)
|
app/api/v1/ppd.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""PPD API endpoints: search, comps, and transaction record lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
|
|
9
|
+
from app.schemas.ppd import (
|
|
10
|
+
PPDCompsResponse,
|
|
11
|
+
PPDDownloadURLResponse,
|
|
12
|
+
PPDSearchResponse,
|
|
13
|
+
PPDTransaction,
|
|
14
|
+
PPDTransactionRecordResponse,
|
|
15
|
+
)
|
|
16
|
+
from property_core.enrichment import compute_enriched_stats, enrich_comps_with_epc
|
|
17
|
+
from property_core.ppd_service import PPDService
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/ppd", tags=["ppd"])
|
|
20
|
+
service = PPDService()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/download-url", response_model=PPDDownloadURLResponse)
|
|
24
|
+
def download_url(
|
|
25
|
+
kind: Literal["complete", "year", "monthly"] = "monthly",
|
|
26
|
+
year: Optional[int] = Query(None, ge=1995),
|
|
27
|
+
part: Optional[int] = Query(None, ge=1, le=2),
|
|
28
|
+
fmt: Literal["csv", "txt"] = "csv",
|
|
29
|
+
) -> PPDDownloadURLResponse:
|
|
30
|
+
"""Return a direct Land Registry download URL for bulk datasets."""
|
|
31
|
+
try:
|
|
32
|
+
url = service.download_url(kind=kind, year=year, part=part, fmt=fmt)
|
|
33
|
+
return PPDDownloadURLResponse(url=url)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/transactions", response_model=PPDSearchResponse)
|
|
39
|
+
def transactions(
|
|
40
|
+
postcode: Optional[str] = Query(None, min_length=2),
|
|
41
|
+
postcode_prefix: Optional[str] = Query(None, min_length=2),
|
|
42
|
+
from_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
|
43
|
+
to_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
|
44
|
+
min_price: Optional[int] = Query(None, ge=0),
|
|
45
|
+
max_price: Optional[int] = Query(None, ge=0),
|
|
46
|
+
property_type: Optional[str] = Query(None, description="D/S/T/F/O"),
|
|
47
|
+
estate_type: Optional[str] = Query(None, description="F/L"),
|
|
48
|
+
transaction_category: Optional[str] = Query(None, description="A/B"),
|
|
49
|
+
record_status: Optional[str] = Query(None, description="A/C/D"),
|
|
50
|
+
new_build: Optional[bool] = None,
|
|
51
|
+
limit: int = Query(50, ge=1, le=200),
|
|
52
|
+
offset: int = Query(0, ge=0),
|
|
53
|
+
order_desc: bool = True,
|
|
54
|
+
include_raw: bool = Query(False, description="Include raw SPARQL bindings"),
|
|
55
|
+
) -> PPDSearchResponse:
|
|
56
|
+
"""Search PPD transactions using postcode or postcode prefix (district/sector)."""
|
|
57
|
+
if bool(postcode) == bool(postcode_prefix):
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status_code=422,
|
|
60
|
+
detail="Provide exactly one of postcode or postcode_prefix.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
result = service.search_transactions(
|
|
65
|
+
postcode=postcode,
|
|
66
|
+
postcode_prefix=postcode_prefix,
|
|
67
|
+
from_date=from_date,
|
|
68
|
+
to_date=to_date,
|
|
69
|
+
min_price=min_price,
|
|
70
|
+
max_price=max_price,
|
|
71
|
+
property_type=property_type,
|
|
72
|
+
estate_type=estate_type,
|
|
73
|
+
transaction_category=transaction_category,
|
|
74
|
+
record_status=record_status,
|
|
75
|
+
new_build=new_build,
|
|
76
|
+
limit=limit,
|
|
77
|
+
offset=offset,
|
|
78
|
+
order_desc=order_desc,
|
|
79
|
+
include_raw=include_raw,
|
|
80
|
+
)
|
|
81
|
+
return PPDSearchResponse(**result)
|
|
82
|
+
except Exception as exc: # noqa: BLE001
|
|
83
|
+
raise HTTPException(status_code=502, detail=f"PPD search failed: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/address-search", response_model=PPDSearchResponse)
|
|
87
|
+
def address_search(
|
|
88
|
+
paon: Optional[str] = Query(None, min_length=1, description="Primary addressable object name"),
|
|
89
|
+
saon: Optional[str] = Query(None, min_length=1, description="Secondary addressable object name"),
|
|
90
|
+
street: Optional[str] = Query(None, min_length=2),
|
|
91
|
+
town: Optional[str] = Query(None, min_length=2),
|
|
92
|
+
county: Optional[str] = Query(None, min_length=2),
|
|
93
|
+
postcode: Optional[str] = Query(None, min_length=2),
|
|
94
|
+
postcode_prefix: Optional[str] = Query(None, min_length=2),
|
|
95
|
+
limit: int = Query(25, ge=1, le=50),
|
|
96
|
+
include_raw: bool = Query(False, description="Include raw SPARQL bindings"),
|
|
97
|
+
) -> PPDSearchResponse:
|
|
98
|
+
"""Web-form style address search (requires at least two fields)."""
|
|
99
|
+
provided = [v for v in (paon, saon, street, town, county, postcode, postcode_prefix) if v]
|
|
100
|
+
if len(provided) < 2:
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=422, detail="Provide at least two address fields (e.g., postcode + street)."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
result = service.address_search(
|
|
107
|
+
paon=paon,
|
|
108
|
+
saon=saon,
|
|
109
|
+
street=street,
|
|
110
|
+
town=town,
|
|
111
|
+
county=county,
|
|
112
|
+
postcode=postcode,
|
|
113
|
+
postcode_prefix=postcode_prefix,
|
|
114
|
+
limit=limit,
|
|
115
|
+
include_raw=include_raw,
|
|
116
|
+
)
|
|
117
|
+
return PPDSearchResponse(**result)
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
120
|
+
except Exception as exc: # noqa: BLE001
|
|
121
|
+
raise HTTPException(status_code=502, detail=f"PPD address search failed: {exc}") from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@router.get("/comps", response_model=PPDCompsResponse)
|
|
125
|
+
async def comps(
|
|
126
|
+
postcode: str = Query(..., min_length=2),
|
|
127
|
+
property_type: Optional[str] = Query(None, description="D/S/T/F/O"),
|
|
128
|
+
months: int = Query(24, ge=1, le=120),
|
|
129
|
+
limit: int = Query(50, ge=1, le=200),
|
|
130
|
+
search_level: Literal["postcode", "sector", "district"] = "sector",
|
|
131
|
+
address: Optional[str] = Query(None, description="Subject property address for context"),
|
|
132
|
+
enrich_epc: bool = Query(False, description="Enrich comps with EPC floor area and price/sqft"),
|
|
133
|
+
) -> PPDCompsResponse:
|
|
134
|
+
"""Get comparable sales summary for a postcode (sector/district supported).
|
|
135
|
+
|
|
136
|
+
If address is provided, returns subject_property with its transaction history.
|
|
137
|
+
If enrich_epc is True, attaches EPC floor area and price-per-sqft to each comp.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
result = service.comps(
|
|
141
|
+
postcode=postcode,
|
|
142
|
+
property_type=property_type,
|
|
143
|
+
months=months,
|
|
144
|
+
limit=limit,
|
|
145
|
+
search_level=search_level,
|
|
146
|
+
address=address,
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc: # noqa: BLE001
|
|
149
|
+
raise HTTPException(status_code=502, detail=f"PPD comps failed: {exc}") from exc
|
|
150
|
+
|
|
151
|
+
if enrich_epc:
|
|
152
|
+
comp_dicts = [t.model_dump() for t in result.transactions]
|
|
153
|
+
enriched = await enrich_comps_with_epc(comp_dicts)
|
|
154
|
+
result.transactions = [PPDTransaction(**d) for d in enriched]
|
|
155
|
+
compute_enriched_stats(result)
|
|
156
|
+
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.get("/transaction/{transaction_id}", response_model=PPDTransactionRecordResponse)
|
|
161
|
+
def transaction_record(
|
|
162
|
+
transaction_id: str,
|
|
163
|
+
view: str = Query("all", description="Linked Data view (e.g., all, basic)"),
|
|
164
|
+
include_raw: bool = Query(False, description="Include raw linked-data JSON"),
|
|
165
|
+
) -> PPDTransactionRecordResponse:
|
|
166
|
+
"""Fetch and normalize a single transaction record from the Linked Data API."""
|
|
167
|
+
try:
|
|
168
|
+
result = service.transaction_record(
|
|
169
|
+
transaction_id=transaction_id,
|
|
170
|
+
view=view,
|
|
171
|
+
include_raw=include_raw,
|
|
172
|
+
)
|
|
173
|
+
return PPDTransactionRecordResponse(**result)
|
|
174
|
+
except Exception as exc: # noqa: BLE001
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=502,
|
|
177
|
+
detail=f"PPD transaction lookup failed: {exc}",
|
|
178
|
+
) from exc
|
app/api/v1/report.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Property report endpoint: aggregated property assessment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
from fastapi.responses import HTMLResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from property_core.models.report import PropertyReport
|
|
12
|
+
from property_core.report_service import PropertyReportService
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/property", tags=["property"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReportRequest(BaseModel):
|
|
18
|
+
"""Request to generate a property report."""
|
|
19
|
+
address: str
|
|
20
|
+
include_rentals: bool = True
|
|
21
|
+
include_sales_market: bool = True
|
|
22
|
+
ppd_months: int = 24
|
|
23
|
+
search_radius: float = 0.5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post("/report", response_model=PropertyReport)
|
|
27
|
+
async def generate_report(
|
|
28
|
+
request: ReportRequest,
|
|
29
|
+
format: Optional[str] = Query(None, description="Response format: 'html' or default JSON"),
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Generate a comprehensive property report.
|
|
33
|
+
|
|
34
|
+
Aggregates data from Land Registry (PPD), EPC register, and Rightmove
|
|
35
|
+
(rentals + sales) into a single assessment with estimated value range,
|
|
36
|
+
yield analysis, and market context.
|
|
37
|
+
|
|
38
|
+
Address format: "10 Downing Street, SW1A 2AA" (postcode at end).
|
|
39
|
+
"""
|
|
40
|
+
service = PropertyReportService()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
report = await service.generate_report(
|
|
44
|
+
address_query=request.address,
|
|
45
|
+
include_rentals=request.include_rentals,
|
|
46
|
+
include_sales_market=request.include_sales_market,
|
|
47
|
+
ppd_months=request.ppd_months,
|
|
48
|
+
search_radius=request.search_radius,
|
|
49
|
+
)
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise HTTPException(status_code=502, detail=f"Report generation failed: {e}") from e
|
|
54
|
+
|
|
55
|
+
if format == "html":
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from jinja2 import Environment, FileSystemLoader
|
|
59
|
+
|
|
60
|
+
template_dir = Path(__file__).parent.parent.parent / "templates"
|
|
61
|
+
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
62
|
+
template = env.get_template("report.html")
|
|
63
|
+
html = template.render(report=report)
|
|
64
|
+
return HTMLResponse(content=html)
|
|
65
|
+
|
|
66
|
+
return report
|