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.
Files changed (58) hide show
  1. app/__init__.py +1 -0
  2. app/api/__init__.py +1 -0
  3. app/api/routes.py +12 -0
  4. app/api/v1/__init__.py +1 -0
  5. app/api/v1/epc.py +90 -0
  6. app/api/v1/health.py +8 -0
  7. app/api/v1/meta.py +29 -0
  8. app/api/v1/planning.py +249 -0
  9. app/api/v1/ppd.py +178 -0
  10. app/api/v1/report.py +66 -0
  11. app/api/v1/rightmove.py +75 -0
  12. app/clients/http.py +8 -0
  13. app/core/__init__.py +1 -0
  14. app/core/config.py +37 -0
  15. app/core/logging.py +12 -0
  16. app/main.py +40 -0
  17. app/py.typed +0 -0
  18. app/schemas/__init__.py +1 -0
  19. app/schemas/epc.py +21 -0
  20. app/schemas/ppd.py +41 -0
  21. app/schemas/report.py +17 -0
  22. app/schemas/rightmove.py +36 -0
  23. app/services/__init__.py +1 -0
  24. app/services/epc_service.py +52 -0
  25. app/services/rightmove_service.py +95 -0
  26. app/tasks/__init__.py +1 -0
  27. app/templates/report.html +370 -0
  28. app/utils/polite.py +34 -0
  29. app/web/__init__.py +2 -0
  30. app/web/routes.py +16 -0
  31. app/web/templates/index.html +98 -0
  32. property_cli/__init__.py +2 -0
  33. property_cli/main.py +945 -0
  34. property_cli/py.typed +0 -0
  35. property_core/__init__.py +31 -0
  36. property_core/enrichment.py +143 -0
  37. property_core/epc_client.py +266 -0
  38. property_core/models/__init__.py +40 -0
  39. property_core/models/epc.py +52 -0
  40. property_core/models/ppd.py +95 -0
  41. property_core/models/report.py +125 -0
  42. property_core/models/rightmove.py +89 -0
  43. property_core/planning_councils.json +1249 -0
  44. property_core/planning_diagnostics.py +295 -0
  45. property_core/planning_scraper.py +1134 -0
  46. property_core/planning_service.py +279 -0
  47. property_core/postcode_client.py +69 -0
  48. property_core/ppd_client.py +545 -0
  49. property_core/ppd_service.py +473 -0
  50. property_core/py.typed +0 -0
  51. property_core/rental_service.py +94 -0
  52. property_core/report_service.py +523 -0
  53. property_core/rightmove_location.py +124 -0
  54. property_core/rightmove_scraper.py +537 -0
  55. property_shared-0.1.0.dist-info/METADATA +136 -0
  56. property_shared-0.1.0.dist-info/RECORD +58 -0
  57. property_shared-0.1.0.dist-info/WHEEL +4 -0
  58. 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
@@ -0,0 +1,8 @@
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter()
4
+
5
+
6
+ @router.get("/health", summary="Health check")
7
+ async def health() -> dict[str, str]:
8
+ return {"status": "ok"}
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