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)
This commit is contained in:
Viktor Barzin 2026-02-21 15:48:02 +00:00
parent a2e7d59af2
commit 9e1beb7495
No known key found for this signature in database
GPG key ID: 0EB088298288D958
7 changed files with 447 additions and 138 deletions

View file

@ -6,8 +6,8 @@ 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.passkey_routes import passkey_router
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
@ -19,6 +19,7 @@ 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
@ -28,7 +29,8 @@ 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, decision_service
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,
@ -37,8 +39,8 @@ from services.listing_cache import (
finalize_cache_population,
delete_staging_key,
)
from repositories.decision_repository import DecisionRepository
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
@ -169,13 +171,8 @@ async def get_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
limit: int | None = None,
decision_filter: str = "all",
) -> dict:
"""Get listings as GeoJSON for map display.
decision_filter: 'all' (hide disliked, default), 'liked', 'disliked',
'undecided', 'everything' (no filtering).
"""
"""Get listings as GeoJSON for map display."""
if limit is not None:
limit = min(limit, _rate_limit_config.geojson_limit_cap)
else:
@ -186,36 +183,10 @@ async def get_listing_geojson(
query_parameters=query_parameters,
limit=limit,
)
# Filter features based on decision_filter
if decision_filter != "everything":
disliked_ids = _get_disliked_ids(
user.email, query_parameters.listing_type.value
)
if disliked_ids:
str_disliked = {str(lid) for lid in disliked_ids}
result.data["features"] = [
f for f in result.data["features"]
if f.get("properties", {}).get("url", "").split("/")[-1]
not in str_disliked
]
return result.data
def _get_disliked_ids(user_email: str, listing_type: str) -> set[int]:
"""Get the set of disliked listing IDs for a user."""
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 set()
decision_repo = DecisionRepository(engine)
return decision_service.get_disliked_listing_ids(
decision_repo, user_id=db_user.id, listing_type=listing_type
)
def _build_poi_distances_lookup(
user_email: str,
listing_type: ListingType,
@ -250,10 +221,41 @@ def _build_poi_distances_lookup(
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)
@ -268,10 +270,22 @@ async def _stream_from_cache(
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)
yield json.dumps({"type": "batch", "features": feature_batch}) + "\n"
if feature_batch:
yield json.dumps({"type": "batch", "features": feature_batch}) + "\n"
if limit and count >= limit:
break
@ -285,6 +299,8 @@ async def _stream_from_db(
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)
@ -309,13 +325,18 @@ async def _stream_from_db(
for row in repository.stream_listings_optimized(
query_parameters, limit=limit, page_size=batch_size
):
# Skip disliked listings
if disliked_ids and row['id'] in disliked_ids:
continue
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
@ -349,6 +370,7 @@ async def stream_listing_geojson(
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.
@ -356,6 +378,11 @@ async def stream_listing_geojson(
- 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:
@ -366,21 +393,41 @@ async def stream_listing_geojson(
# 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
# Get disliked listing IDs to exclude from stream
disliked_ids = _get_disliked_ids(
user.email, query_parameters.listing_type.value
)
# 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)
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 if disliked_ids else None,
disliked_ids=disliked_ids,
liked_ids=liked_ids,
decision_filter=decision_filter,
)
return StreamingResponse(
@ -491,4 +538,167 @@ async def get_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)

View file

@ -1,8 +1,7 @@
"""API routes for listing decisions (like/dislike)."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from api.auth import User, get_current_user
@ -18,11 +17,10 @@ decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"])
class SetDecisionRequest(BaseModel):
decision: str = Field(description="'liked' or 'disliked'")
listing_type: str = Field(default="RENT", description="'RENT' or 'BUY'")
listing_type: str = Field(description="'RENT' or 'BUY'")
class DecisionResponse(BaseModel):
id: int
listing_id: int
listing_type: str
decision: str
@ -35,19 +33,30 @@ def _get_user_id(user: User) -> int:
user_repo = UserRepository(engine)
db_user = user_repo.get_user_by_email(user.email)
if db_user is None:
# Auto-create user on first decision interaction
db_user = user_repo.create_user(user.email)
if db_user.id is None:
raise HTTPException(status_code=500, detail="Failed to create user")
return db_user.id
def _to_response(d: decision_service.ListingDecision) -> DecisionResponse:
return DecisionResponse(
listing_id=d.listing_id,
listing_type=d.listing_type,
decision=d.decision,
created_at=d.created_at.isoformat(),
updated_at=d.updated_at.isoformat(),
)
@decision_router.put("/{listing_id}", response_model=DecisionResponse)
async def set_decision(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
body: SetDecisionRequest,
) -> DecisionResponse:
"""Set a decision (like/dislike) on a listing."""
"""Set or update a like/dislike decision for a listing."""
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
try:
@ -60,14 +69,7 @@ async def set_decision(
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return DecisionResponse(
id=result.id, # type: ignore[arg-type]
listing_id=result.listing_id,
listing_type=result.listing_type,
decision=result.decision,
created_at=result.created_at.isoformat(),
updated_at=result.updated_at.isoformat(),
)
return _to_response(result)
@decision_router.get("", response_model=list[DecisionResponse])
@ -77,35 +79,25 @@ async def get_decisions(
"""Get all decisions for the current user."""
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
decisions = decision_service.get_decisions(repo, user_id=user_id)
return [
DecisionResponse(
id=d.id, # type: ignore[arg-type]
listing_id=d.listing_id,
listing_type=d.listing_type,
decision=d.decision,
created_at=d.created_at.isoformat(),
updated_at=d.updated_at.isoformat(),
)
for d in decisions
]
decisions = decision_service.get_user_decisions(repo, user_id)
return [_to_response(d) for d in decisions]
@decision_router.delete("/{listing_id}")
async def delete_decision(
user: Annotated[User, Depends(get_current_user)],
listing_id: int,
listing_type: str = "RENT",
listing_type: str = Query(..., description="RENT or BUY"),
) -> dict[str, bool]:
"""Remove a decision (back to neutral)."""
"""Remove a decision (un-like/un-dislike)."""
user_id = _get_user_id(user)
repo = DecisionRepository(engine)
deleted = decision_service.clear_decision(
repo,
user_id=user_id,
listing_id=listing_id,
listing_type=listing_type,
)
try:
deleted = decision_service.remove_decision(
repo, user_id, listing_id, listing_type
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not deleted:
raise HTTPException(status_code=404, detail="Decision not found")
return {"success": True}