wrongmove/api/app.py
Viktor Barzin 9e1beb7495
Add listing decisions (like/dislike) backend with detail endpoint
- ListingDecision model with unique constraint on (user_id, listing_id, listing_type)
- Alembic migration for listingdecision table
- DecisionRepository with dialect-aware upsert (MySQL/SQLite)
- DecisionService with input validation
- Decision API routes: PUT/GET/DELETE on /api/decisions
- GET /api/listing/{id}/detail endpoint extracting full property info from additional_info
- Add listing ID to GeoJSON feature properties
- Decision filtering on GeoJSON stream endpoint (decision_filter param)
2026-02-21 15:49:10 +00:00

704 lines
25 KiB
Python

"""FastAPI application for the Real Estate Crawler API."""
from datetime import datetime, timedelta
import json
import logging
import logging.config
from typing import Annotated, AsyncGenerator, Optional
from api.auth import get_current_user
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV
from api.decision_routes import decision_router
from api.passkey_routes import passkey_router
from api.poi_routes import poi_router
from api.ws_routes import ws_router
from api.rate_limit_config import RateLimitConfig
from api.rate_limiter import RateLimitMiddleware
from api.audit_middleware import AuditLogMiddleware
from api.metrics_guard import MetricsGuardMiddleware
from api.security_headers import SecurityHeadersMiddleware
from api.origin_validator import OriginValidatorMiddleware
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from starlette.requests import Request
from api.auth import User
from models.listing import QueryParameters, ListingType, FurnishType
from notifications import send_notification
from repositories.listing_repository import ListingRepository
from database import engine
from fastapi.middleware.cors import CORSMiddleware
from ui_exporter import convert_to_geojson_feature, convert_row_to_geojson
from services import listing_service, export_service, district_service, task_service
from services import decision_service
from services.listing_cache import (
get_cached_count,
get_cached_features,
begin_cache_population,
cache_features_batch_staged,
finalize_cache_population,
delete_staging_key,
)
from repositories.poi_repository import POIRepository
from repositories.decision_repository import DecisionRepository
from repositories.user_repository import UserRepository
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from api.metrics import init_metrics, get_metrics_asgi_app
import api.metrics as app_metrics
from logging_config import configure_logging
load_dotenv()
configure_logging("api")
logger = logging.getLogger(__name__)
DEFAULT_BATCH_SIZE = 50
_rate_limit_config = RateLimitConfig.from_env()
def get_query_parameters(
listing_type: ListingType,
min_bedrooms: int = 1,
max_bedrooms: int = 999,
min_price: int = 0,
max_price: int = 10_000_000,
min_sqm: Optional[int] = None,
max_sqm: Optional[int] = None,
min_price_per_sqm: Optional[float] = None,
max_price_per_sqm: Optional[float] = None,
last_seen_days: Optional[int] = None,
let_date_available_from: Optional[datetime] = None,
furnish_types: Optional[str] = None, # comma-separated list
district_names: Optional[str] = None, # comma-separated list
) -> QueryParameters:
"""Parse query parameters into QueryParameters model."""
parsed_furnish_types = None
if furnish_types:
parsed_furnish_types = [FurnishType(f.strip()) for f in furnish_types.split(",")]
parsed_district_names: set[str] = set()
if district_names:
parsed_district_names = {d.strip() for d in district_names.split(",") if d.strip()}
return QueryParameters(
listing_type=listing_type,
min_bedrooms=min_bedrooms,
max_bedrooms=max_bedrooms,
min_price=min_price,
max_price=max_price,
min_sqm=min_sqm,
max_sqm=max_sqm,
min_price_per_sqm=min_price_per_sqm,
max_price_per_sqm=max_price_per_sqm,
last_seen_days=last_seen_days,
let_date_available_from=let_date_available_from,
furnish_types=parsed_furnish_types,
district_names=parsed_district_names,
)
app = FastAPI(
docs_url=None if APP_ENV == "production" else "/docs",
redoc_url=None if APP_ENV == "production" else "/redoc",
openapi_url=None if APP_ENV == "production" else "/openapi.json",
)
app.include_router(passkey_router)
app.include_router(poi_router)
app.include_router(decision_router)
app.include_router(ws_router)
init_metrics("realestate-crawler-api")
app.mount("/metrics", get_metrics_asgi_app())
# Allow CORS (for React frontend)
app.add_middleware(
CORSMiddleware,
allow_origins=[*DEV_TIER_ORIGINS, *PROD_TIER_ORIGINS],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
app.add_middleware(
OriginValidatorMiddleware,
allowed_origins=[*DEV_TIER_ORIGINS, *PROD_TIER_ORIGINS],
)
# Security middleware (added bottom-to-top; last added = outermost)
# 3. Rate limiting — enforces per-user limits
app.add_middleware(RateLimitMiddleware, config=_rate_limit_config)
# 2. Metrics guard — blocks unauthorized /metrics access
app.add_middleware(MetricsGuardMiddleware, config=_rate_limit_config)
# 1. Audit logging — logs everything including 429s and 403s
app.add_middleware(AuditLogMiddleware)
# 0. Security headers — adds standard security headers to all responses
app.add_middleware(SecurityHeadersMiddleware)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled exception")
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
@app.get("/api/status")
async def get_status() -> dict[str, str | None]:
repository = ListingRepository(engine)
last_updated = repository.get_last_updated()
return {
"status": "OK",
"last_updated": last_updated.isoformat() if last_updated else None,
}
@app.get("/api/listing")
async def get_listing(
user: Annotated[User, Depends(get_current_user)],
limit: int = 5,
) -> dict[str, list]:
"""Get listings from the database."""
limit = min(limit, _rate_limit_config.listing_limit_cap)
repository = ListingRepository(engine)
result = await listing_service.get_listings(repository, limit=limit)
logger.info(f"Fetched {result.total_count} listings for {user.email}")
return {"listings": result.listings}
@app.get("/api/listing_geojson")
async def get_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
limit: int | None = None,
) -> dict:
"""Get listings as GeoJSON for map display."""
if limit is not None:
limit = min(limit, _rate_limit_config.geojson_limit_cap)
else:
limit = _rate_limit_config.geojson_limit_cap
repository = ListingRepository(engine)
result = await export_service.export_to_geojson(
repository,
query_parameters=query_parameters,
limit=limit,
)
return result.data
def _build_poi_distances_lookup(
user_email: str,
listing_type: ListingType,
) -> dict[int, list[dict[str, str | int]]] | None:
"""Build POI distance lookup for a user, or None if no POIs configured."""
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user_email)
if not db_user or db_user.id is None:
return None
poi_repo = POIRepository(engine)
pois = {p.id: p for p in poi_repo.get_pois_for_user(db_user.id)}
if not pois:
return None
listing_repo = ListingRepository(engine)
all_ids = list(listing_repo.get_listing_ids(listing_type))
if not all_ids:
return None
distances = poi_repo.get_distances_for_listings(all_ids, listing_type, db_user.id)
lookup: dict[int, list[dict[str, str | int]]] = {}
for d in distances:
poi_name = pois[d.poi_id].name if d.poi_id in pois else "Unknown"
lookup.setdefault(d.listing_id, []).append({
"poi_id": d.poi_id,
"poi_name": poi_name,
"travel_mode": d.travel_mode,
"duration_seconds": d.duration_seconds,
"distance_meters": d.distance_meters,
})
return lookup
def _get_user_id_safe(user_email: str) -> int | None:
"""Get database user ID by email, or None if user doesn't exist."""
try:
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user_email)
if db_user is None or db_user.id is None:
return None
return db_user.id
except Exception:
logger.debug("Could not look up user ID for %s", user_email)
return None
def _should_include(
feature_id: int,
decision_filter: str,
disliked_ids: set[int] | None,
liked_ids: set[int] | None,
) -> bool:
"""Determine if a listing should be included based on decision filter."""
if decision_filter == "everything":
return True
if decision_filter == "liked":
return liked_ids is not None and feature_id in liked_ids
# default "all": hide disliked
return disliked_ids is None or feature_id not in disliked_ids
async def _stream_from_cache(
query_parameters: QueryParameters,
batch_size: int,
limit: int | None,
disliked_ids: set[int] | None = None,
liked_ids: set[int] | None = None,
decision_filter: str = "all",
) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the Redis cache (cache-hit path)."""
cached_count = get_cached_count(query_parameters)
effective_total = min(limit, cached_count) if limit and cached_count else cached_count
yield json.dumps({
"type": "metadata",
"batch_size": batch_size,
"total_expected": effective_total,
"cached": True,
}) + "\n"
count = 0
for feature_batch in get_cached_features(query_parameters, batch_size=batch_size):
# Apply decision filtering
if decision_filter != "everything":
feature_batch = [
f for f in feature_batch
if _should_include(
f.get("properties", {}).get("id", 0),
decision_filter,
disliked_ids,
liked_ids,
)
]
if limit and count + len(feature_batch) > limit:
feature_batch = feature_batch[:limit - count]
count += len(feature_batch)
if feature_batch:
yield json.dumps({"type": "batch", "features": feature_batch}) + "\n"
if limit and count >= limit:
break
yield json.dumps({"type": "complete", "total": count}) + "\n"
async def _stream_from_db(
query_parameters: QueryParameters,
batch_size: int,
limit: int | None,
poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None,
skip_cache: bool = False,
disliked_ids: set[int] | None = None,
liked_ids: set[int] | None = None,
decision_filter: str = "all",
) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the database, populating the cache as we go."""
repository = ListingRepository(engine)
total = repository.count_listings(query_parameters)
effective_total = min(limit, total) if limit else total
yield json.dumps({
"type": "metadata",
"batch_size": batch_size,
"total_expected": effective_total,
"cached": False,
}) + "\n"
staging_key: str | None = None
if not skip_cache:
staging_key = begin_cache_population(query_parameters)
try:
count = 0
batch: list[dict] = []
for row in repository.stream_listings_optimized(
query_parameters, limit=limit, page_size=batch_size
):
feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
# Inject POI distances if available
if poi_distances_lookup and row['id'] in poi_distances_lookup:
feature['properties']['poi_distances'] = poi_distances_lookup[row['id']]
# Apply decision filtering
if not _should_include(row['id'], decision_filter, disliked_ids, liked_ids):
# Still cache the feature (it's valid data), just don't stream it
if staging_key:
cache_features_batch_staged(staging_key, [feature])
continue
batch.append(feature)
count += 1
if len(batch) >= batch_size:
if staging_key:
cache_features_batch_staged(staging_key, batch)
yield json.dumps({"type": "batch", "features": batch}) + "\n"
batch = []
if batch:
if staging_key:
cache_features_batch_staged(staging_key, batch)
yield json.dumps({"type": "batch", "features": batch}) + "\n"
# Atomically promote staged data to live cache
if staging_key:
finalize_cache_population(staging_key, query_parameters)
staging_key = None # Mark as finalized
yield json.dumps({"type": "complete", "total": count}) + "\n"
finally:
# Clean up orphaned staging key on failure
if staging_key:
delete_staging_key(staging_key)
@app.get("/api/listing_geojson/stream")
async def stream_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
include_poi_distances: bool = False,
decision_filter: str = "all",
) -> StreamingResponse:
"""Stream listings as NDJSON for progressive map loading.
Returns newline-delimited JSON with three message types:
- metadata: Initial message with batch_size and total_expected count
- batch: Array of GeoJSON features
- complete: Final message with total count
Decision filter options:
- "all" (default): Show all listings except disliked ones
- "liked": Show only liked listings
- "everything": Show all listings including disliked
"""
batch_size = min(batch_size, _rate_limit_config.geojson_stream_batch_size_cap)
if limit is not None:
limit = min(limit, _rate_limit_config.geojson_stream_limit_cap)
else:
limit = _rate_limit_config.geojson_stream_limit_cap
# Build POI distances lookup if requested
poi_distances_lookup = _build_poi_distances_lookup(user.email, query_parameters.listing_type) if include_poi_distances else None
# Build decision filter sets
disliked_ids: set[int] | None = None
liked_ids: set[int] | None = None
if decision_filter != "everything":
user_id = _get_user_id_safe(user.email)
if user_id is not None:
decision_repo = DecisionRepository(engine)
listing_type_str = query_parameters.listing_type.value
if decision_filter == "liked":
liked_ids = decision_service.get_liked_ids(
decision_repo, user_id, listing_type_str
)
else:
# default "all": load disliked to exclude
disliked_ids = decision_service.get_disliked_ids(
decision_repo, user_id, listing_type_str
)
cached_count = get_cached_count(query_parameters)
if cached_count is not None and cached_count > 0 and not include_poi_distances:
app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
generator = _stream_from_cache(
query_parameters, batch_size, limit,
disliked_ids=disliked_ids,
liked_ids=liked_ids,
decision_filter=decision_filter,
)
else:
app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
generator = _stream_from_db(
query_parameters, batch_size, limit, poi_distances_lookup,
skip_cache=include_poi_distances,
disliked_ids=disliked_ids,
liked_ids=liked_ids,
decision_filter=decision_filter,
)
return StreamingResponse(
generator,
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
)
@app.post("/api/refresh_listings")
async def refresh_listings(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
) -> dict[str, str]:
"""Trigger a background task to refresh listings."""
await send_notification(
f"{user.email} refreshing listings with query parameters {query_parameters.model_dump_json()}"
)
repository = ListingRepository(engine)
result = await listing_service.refresh_listings(
repository,
query_parameters,
async_mode=True,
user_email=user.email,
)
# Track task for user
if result.task_id:
task_service.add_task_for_user(user.email, result.task_id)
return {"task_id": result.task_id or "", "message": result.message}
@app.get("/api/task_status")
async def get_task_status(
user: Annotated[User, Depends(get_current_user)],
task_id: str,
) -> dict[str, str | int | float | None]:
"""Get the status of a background task."""
user_tasks = task_service.get_user_tasks(user.email)
if task_id not in user_tasks:
raise HTTPException(status_code=404, detail="Task not found")
status = task_service.get_task_status(task_id)
return {
"task_id": status.task_id,
"status": status.status,
"result": json.dumps(status.result) if status.result else None,
"progress": status.progress,
"processed": status.processed,
"total": status.total,
"message": status.message,
"error": status.error if APP_ENV != "production" else None,
"traceback": status.traceback if APP_ENV != "production" else None,
}
@app.get("/api/tasks_for_user")
async def get_tasks_for_user(
user: Annotated[User, Depends(get_current_user)],
) -> list[str]:
"""Get all task IDs for the current user."""
return task_service.get_user_tasks(user.email)
@app.post("/api/cancel_task")
async def cancel_task(
user: Annotated[User, Depends(get_current_user)],
task_id: str = Query(..., description="The task ID to cancel"),
) -> dict[str, str | bool]:
"""Cancel a running task and remove it from the user's task list."""
# Verify user owns this task
user_tasks = task_service.get_user_tasks(user.email)
if task_id not in user_tasks:
raise HTTPException(status_code=404, detail="Task not found or not owned by user")
try:
task_service.cancel_task(task_id, user_email=user.email)
logger.info(f"Task {task_id} cancelled by {user.email}")
return {"success": True, "message": "Task cancelled"}
except Exception as e:
logger.error(f"Failed to cancel task {task_id}: {e}")
return {"success": False, "message": str(e)}
@app.post("/api/clear_all_tasks")
async def clear_all_tasks(
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str | int | bool]:
"""Clear all tasks for the current user."""
try:
count = task_service.clear_all_tasks(user.email)
logger.info(f"Cleared {count} tasks for {user.email}")
return {"success": True, "count": count, "message": f"Cleared {count} tasks"}
except Exception as e:
logger.error(f"Failed to clear tasks for {user.email}: {e}")
return {"success": False, "count": 0, "message": str(e)}
@app.get("/api/get_districts")
async def get_districts(
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
"""Get all available districts."""
return district_service.get_all_districts()
class ListingDetailResponse(BaseModel):
id: int
price: float
number_of_bedrooms: int
square_meters: float | None
agency: str | None
council_tax_band: str | None
url: str
listing_type: str
description: str | None
display_address: str | None
property_sub_type: str | None
key_features: list[str]
photos: list[dict]
floorplans: list[dict]
price_history: list[dict]
furnish_type: str | None
available_from: str | None
service_charge: float | None
lease_left: int | None
decision: str | None
poi_distances: list[dict]
@app.get("/api/listing/{listing_id}/detail", response_model=ListingDetailResponse)
async def get_listing_detail(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
listing_type: str = Query(default="RENT"),
) -> ListingDetailResponse:
"""Get detailed information for a single listing."""
repository = ListingRepository(engine)
lt = ListingType(listing_type)
listings = await repository.get_listings(
only_ids=[listing_id], listing_type=lt
)
if not listings:
raise HTTPException(status_code=404, detail="Listing not found")
listing = listings[0]
additional_info = listing.additional_info or {}
property_info = additional_info.get("property", {})
# Extract description
text_info = property_info.get("text", {})
description = text_info.get("description") if isinstance(text_info, dict) else None
# Extract photos
photos_raw = property_info.get("images", [])
photos: list[dict] = []
if isinstance(photos_raw, list):
for img in photos_raw:
if isinstance(img, dict):
photos.append({
"url": img.get("url", ""),
"caption": img.get("caption", ""),
"type": img.get("type", ""),
})
# Extract floorplans
floorplans_raw = property_info.get("floorplans", [])
floorplans: list[dict] = []
if isinstance(floorplans_raw, list):
for fp in floorplans_raw:
if isinstance(fp, dict):
floorplans.append({
"url": fp.get("url", ""),
"caption": fp.get("caption", ""),
})
# Extract other fields
key_features = property_info.get("keyFeatures", [])
if not isinstance(key_features, list):
key_features = []
display_address_info = property_info.get("address", {})
display_address = (
display_address_info.get("displayAddress")
if isinstance(display_address_info, dict)
else None
)
property_sub_type = property_info.get("propertySubType")
council_tax_band = property_info.get("councilTaxBand") or listing.council_tax_band
furnish_type_val = property_info.get("letFurnishType")
available_from_val = property_info.get("letDateAvailable")
# Price history
price_history = [item.to_dict() for item in listing.price_history]
# Service charge and lease (for BuyListing)
service_charge: float | None = None
lease_left: int | None = None
if hasattr(listing, "service_charge"):
service_charge = listing.service_charge # type: ignore[union-attr]
if hasattr(listing, "lease_left"):
lease_left = listing.lease_left # type: ignore[union-attr]
# Available from (for RentListing)
if available_from_val is None and hasattr(listing, "available_from"):
af = listing.available_from # type: ignore[union-attr]
if af is not None:
available_from_val = af.isoformat() if hasattr(af, "isoformat") else str(af)
# Furnish type (for RentListing)
if furnish_type_val is None and hasattr(listing, "furnish_type"):
ft = listing.furnish_type # type: ignore[union-attr]
if ft is not None:
furnish_type_val = str(ft)
# Load user's decision for this listing
decision_val: str | None = None
user_id = _get_user_id_safe(user.email)
if user_id is not None:
decision_repo = DecisionRepository(engine)
decisions = decision_repo.get_decisions_for_user(user_id)
for d in decisions:
if d.listing_id == listing_id and d.listing_type == listing_type:
decision_val = d.decision
break
# Load POI distances
poi_distances_list: list[dict] = []
if user_id is not None:
poi_repo = POIRepository(engine)
pois = {p.id: p for p in poi_repo.get_pois_for_user(user_id)}
if pois:
distances = poi_repo.get_distances_for_listings(
[listing_id], lt, user_id
)
for d in distances:
poi_name = pois[d.poi_id].name if d.poi_id in pois else "Unknown"
poi_distances_list.append({
"poi_id": d.poi_id,
"poi_name": poi_name,
"travel_mode": d.travel_mode,
"duration_seconds": d.duration_seconds,
"distance_meters": d.distance_meters,
})
return ListingDetailResponse(
id=listing.id,
price=listing.price,
number_of_bedrooms=listing.number_of_bedrooms,
square_meters=listing.square_meters,
agency=listing.agency,
council_tax_band=council_tax_band,
url=listing.url,
listing_type=listing_type,
description=description,
display_address=display_address,
property_sub_type=property_sub_type,
key_features=key_features,
photos=photos,
floorplans=floorplans,
price_history=price_history,
furnish_type=furnish_type_val,
available_from=available_from_val,
service_charge=service_charge,
lease_left=lease_left,
decision=decision_val,
poi_distances=poi_distances_list,
)
FastAPIInstrumentor.instrument_app(app)