feat: filter disliked listings from GeoJSON endpoints

Both /api/listing_geojson and /api/listing_geojson/stream now exclude
disliked listings by default. A decision_filter='everything' param
bypasses filtering. 2 integration tests verify the behavior.
This commit is contained in:
Viktor Barzin 2026-02-21 13:57:43 +00:00
parent 43084ef19a
commit 8452f65d25
No known key found for this signature in database
GPG key ID: 0EB088298288D958
2 changed files with 165 additions and 2 deletions

View file

@ -28,7 +28,7 @@ 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 listing_service, export_service, district_service, task_service, decision_service
from services.listing_cache import (
get_cached_count,
get_cached_features,
@ -37,6 +37,7 @@ 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.user_repository import UserRepository
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
@ -168,8 +169,13 @@ 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."""
"""Get listings as GeoJSON for map display.
decision_filter: 'all' (hide disliked, default), 'liked', 'disliked',
'undecided', 'everything' (no filtering).
"""
if limit is not None:
limit = min(limit, _rate_limit_config.geojson_limit_cap)
else:
@ -180,10 +186,36 @@ 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,
@ -252,6 +284,7 @@ async def _stream_from_db(
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,
) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the database, populating the cache as we go."""
repository = ListingRepository(engine)
@ -276,6 +309,9 @@ 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:
@ -330,6 +366,11 @@ 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
)
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"})
@ -339,6 +380,7 @@ async def stream_listing_geojson(
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,
)
return StreamingResponse(