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

@ -0,0 +1,49 @@
"""add listing decision
Revision ID: d7e8f9a0b1c2
Revises: c5d6e7f8a9b0
Create Date: 2026-02-21 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'd7e8f9a0b1c2'
down_revision: Union[str, None] = 'c5d6e7f8a9b0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create listingdecision table."""
op.create_table('listingdecision',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('listing_id', sa.Integer(), nullable=False),
sa.Column('listing_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('decision', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True,
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'listing_id', 'listing_type',
name='uq_decision_user_listing_type'),
)
op.create_index(op.f('ix_listingdecision_user_id'),
'listingdecision', ['user_id'], unique=False)
op.create_index(op.f('ix_listingdecision_listing_id'),
'listingdecision', ['listing_id'], unique=False)
def downgrade() -> None:
"""Drop listingdecision table."""
op.drop_index(op.f('ix_listingdecision_listing_id'), table_name='listingdecision')
op.drop_index(op.f('ix_listingdecision_user_id'), table_name='listingdecision')
op.drop_table('listingdecision')

View file

@ -6,8 +6,8 @@ import logging.config
from typing import Annotated, AsyncGenerator, Optional from typing import Annotated, AsyncGenerator, Optional
from api.auth import get_current_user from api.auth import get_current_user
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV 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.decision_routes import decision_router
from api.passkey_routes import passkey_router
from api.poi_routes import poi_router from api.poi_routes import poi_router
from api.ws_routes import ws_router from api.ws_routes import ws_router
from api.rate_limit_config import RateLimitConfig from api.rate_limit_config import RateLimitConfig
@ -19,6 +19,7 @@ from api.origin_validator import OriginValidatorMiddleware
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Query from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from starlette.requests import Request from starlette.requests import Request
from api.auth import User from api.auth import User
from models.listing import QueryParameters, ListingType, FurnishType from models.listing import QueryParameters, ListingType, FurnishType
@ -28,7 +29,8 @@ from database import engine
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from ui_exporter import convert_to_geojson_feature, convert_row_to_geojson 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 ( from services.listing_cache import (
get_cached_count, get_cached_count,
get_cached_features, get_cached_features,
@ -37,8 +39,8 @@ from services.listing_cache import (
finalize_cache_population, finalize_cache_population,
delete_staging_key, delete_staging_key,
) )
from repositories.decision_repository import DecisionRepository
from repositories.poi_repository import POIRepository from repositories.poi_repository import POIRepository
from repositories.decision_repository import DecisionRepository
from repositories.user_repository import UserRepository from repositories.user_repository import UserRepository
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from api.metrics import init_metrics, get_metrics_asgi_app 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)], user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)], query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
limit: int | None = None, limit: int | None = None,
decision_filter: str = "all",
) -> dict: ) -> 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: if limit is not None:
limit = min(limit, _rate_limit_config.geojson_limit_cap) limit = min(limit, _rate_limit_config.geojson_limit_cap)
else: else:
@ -186,36 +183,10 @@ async def get_listing_geojson(
query_parameters=query_parameters, query_parameters=query_parameters,
limit=limit, 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 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( def _build_poi_distances_lookup(
user_email: str, user_email: str,
listing_type: ListingType, listing_type: ListingType,
@ -250,10 +221,41 @@ def _build_poi_distances_lookup(
return 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( async def _stream_from_cache(
query_parameters: QueryParameters, query_parameters: QueryParameters,
batch_size: int, batch_size: int,
limit: int | None, limit: int | None,
disliked_ids: set[int] | None = None,
liked_ids: set[int] | None = None,
decision_filter: str = "all",
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the Redis cache (cache-hit path).""" """Stream GeoJSON features from the Redis cache (cache-hit path)."""
cached_count = get_cached_count(query_parameters) cached_count = get_cached_count(query_parameters)
@ -268,10 +270,22 @@ async def _stream_from_cache(
count = 0 count = 0
for feature_batch in get_cached_features(query_parameters, batch_size=batch_size): 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: if limit and count + len(feature_batch) > limit:
feature_batch = feature_batch[:limit - count] feature_batch = feature_batch[:limit - count]
count += len(feature_batch) 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: if limit and count >= limit:
break break
@ -285,6 +299,8 @@ async def _stream_from_db(
poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None, poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None,
skip_cache: bool = False, skip_cache: bool = False,
disliked_ids: set[int] | None = None, disliked_ids: set[int] | None = None,
liked_ids: set[int] | None = None,
decision_filter: str = "all",
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the database, populating the cache as we go.""" """Stream GeoJSON features from the database, populating the cache as we go."""
repository = ListingRepository(engine) repository = ListingRepository(engine)
@ -309,13 +325,18 @@ async def _stream_from_db(
for row in repository.stream_listings_optimized( for row in repository.stream_listings_optimized(
query_parameters, limit=limit, page_size=batch_size 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) feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
# Inject POI distances if available # Inject POI distances if available
if poi_distances_lookup and row['id'] in poi_distances_lookup: if poi_distances_lookup and row['id'] in poi_distances_lookup:
feature['properties']['poi_distances'] = poi_distances_lookup[row['id']] 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) batch.append(feature)
count += 1 count += 1
@ -349,6 +370,7 @@ async def stream_listing_geojson(
batch_size: int = DEFAULT_BATCH_SIZE, batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None, limit: int | None = None,
include_poi_distances: bool = False, include_poi_distances: bool = False,
decision_filter: str = "all",
) -> StreamingResponse: ) -> StreamingResponse:
"""Stream listings as NDJSON for progressive map loading. """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 - metadata: Initial message with batch_size and total_expected count
- batch: Array of GeoJSON features - batch: Array of GeoJSON features
- complete: Final message with total count - 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) batch_size = min(batch_size, _rate_limit_config.geojson_stream_batch_size_cap)
if limit is not None: if limit is not None:
@ -366,21 +393,41 @@ async def stream_listing_geojson(
# Build POI distances lookup if requested # 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 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 # Build decision filter sets
disliked_ids = _get_disliked_ids( disliked_ids: set[int] | None = None
user.email, query_parameters.listing_type.value 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) cached_count = get_cached_count(query_parameters)
if cached_count is not None and cached_count > 0 and not include_poi_distances: if cached_count is not None and cached_count > 0 and not include_poi_distances:
app_metrics.geojson_cache_operations.add(1, {"result": "hit"}) 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: else:
app_metrics.geojson_cache_operations.add(1, {"result": "miss"}) app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
generator = _stream_from_db( generator = _stream_from_db(
query_parameters, batch_size, limit, poi_distances_lookup, query_parameters, batch_size, limit, poi_distances_lookup,
skip_cache=include_poi_distances, 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( return StreamingResponse(
@ -491,4 +538,167 @@ async def get_districts(
return district_service.get_all_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) FastAPIInstrumentor.instrument_app(app)

View file

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

View file

@ -1,13 +1,20 @@
from datetime import datetime, UTC from datetime import datetime
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field, UniqueConstraint
class ListingDecision(SQLModel, table=True): class ListingDecision(SQLModel, table=True):
__table_args__ = (
UniqueConstraint(
'user_id', 'listing_id', 'listing_type',
name='uq_decision_user_listing_type',
),
)
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(nullable=False, foreign_key="user.id", index=True) user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
listing_id: int = Field(nullable=False, index=True) listing_id: int = Field(nullable=False, index=True)
listing_type: str = Field(nullable=False) # "RENT" or "BUY" listing_type: str = Field(nullable=False) # "RENT" or "BUY"
decision: str = Field(nullable=False) # "liked" or "disliked" decision: str = Field(nullable=False) # "liked" or "disliked"
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=datetime.utcnow)

View file

@ -1,5 +1,4 @@
"""Repository for listing decisions (like/dislike).""" from datetime import datetime
from datetime import datetime, UTC
from models.decision import ListingDecision from models.decision import ListingDecision
from sqlalchemy import Engine from sqlalchemy import Engine
@ -13,32 +12,53 @@ class DecisionRepository:
self.engine = engine self.engine = engine
def upsert_decision( def upsert_decision(
self, user_id: int, listing_id: int, listing_type: str, decision: str self,
user_id: int,
listing_id: int,
listing_type: str,
decision: str,
) -> ListingDecision: ) -> ListingDecision:
"""Create or update a decision. Uses dialect-specific upsert."""
with Session(self.engine) as session: with Session(self.engine) as session:
statement = select(ListingDecision).where( now = datetime.utcnow()
ListingDecision.user_id == user_id, values = {
ListingDecision.listing_id == listing_id, "user_id": user_id,
ListingDecision.listing_type == listing_type, "listing_id": listing_id,
) "listing_type": listing_type,
existing = session.exec(statement).first() "decision": decision,
if existing: "created_at": now,
existing.decision = decision "updated_at": now,
existing.updated_at = datetime.now(UTC) }
session.add(existing) dialect = self.engine.dialect.name
session.commit() if dialect == "mysql":
session.refresh(existing) from sqlalchemy.dialects.mysql import insert as mysql_insert
return existing stmt = mysql_insert(ListingDecision).values(**values)
new_decision = ListingDecision( stmt = stmt.on_duplicate_key_update(
user_id=user_id, decision=stmt.inserted.decision,
listing_id=listing_id, updated_at=stmt.inserted.updated_at,
listing_type=listing_type, )
decision=decision, else:
) from sqlalchemy.dialects.sqlite import insert as sqlite_insert
session.add(new_decision) stmt = sqlite_insert(ListingDecision).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=["user_id", "listing_id", "listing_type"],
set_={
"decision": stmt.excluded.decision,
"updated_at": stmt.excluded.updated_at,
},
)
session.execute(stmt)
session.commit() session.commit()
session.refresh(new_decision) # Fetch the result
return new_decision result = session.exec(
select(ListingDecision).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_id == listing_id,
ListingDecision.listing_type == listing_type,
)
).first()
assert result is not None
return result
def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]: def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]:
with Session(self.engine) as session: with Session(self.engine) as session:
@ -48,23 +68,29 @@ class DecisionRepository:
return list(session.exec(statement).all()) return list(session.exec(statement).all())
def delete_decision( def delete_decision(
self, user_id: int, listing_id: int, listing_type: str self,
user_id: int,
listing_id: int,
listing_type: str,
) -> bool: ) -> bool:
with Session(self.engine) as session: with Session(self.engine) as session:
statement = select(ListingDecision).where( result = session.exec(
ListingDecision.user_id == user_id, select(ListingDecision).where(
ListingDecision.listing_id == listing_id, ListingDecision.user_id == user_id,
ListingDecision.listing_type == listing_type, ListingDecision.listing_id == listing_id,
) ListingDecision.listing_type == listing_type,
existing = session.exec(statement).first() )
if existing is None: ).first()
if result is None:
return False return False
session.delete(existing) session.delete(result)
session.commit() session.commit()
return True return True
def get_disliked_listing_ids( def get_disliked_listing_ids(
self, user_id: int, listing_type: str self,
user_id: int,
listing_type: str,
) -> set[int]: ) -> set[int]:
with Session(self.engine) as session: with Session(self.engine) as session:
statement = select(ListingDecision.listing_id).where( statement = select(ListingDecision.listing_id).where(
@ -73,3 +99,16 @@ class DecisionRepository:
ListingDecision.decision == "disliked", ListingDecision.decision == "disliked",
) )
return {row for row in session.exec(statement).all()} return {row for row in session.exec(statement).all()}
def get_liked_listing_ids(
self,
user_id: int,
listing_type: str,
) -> set[int]:
with Session(self.engine) as session:
statement = select(ListingDecision.listing_id).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_type == listing_type,
ListingDecision.decision == "liked",
)
return {row for row in session.exec(statement).all()}

View file

@ -1,4 +1,8 @@
"""Unified decision service -- shared between CLI and HTTP API.""" """Unified decision service - shared between CLI and HTTP API.
This module provides the core business logic for listing decision operations
(like/dislike). Both the CLI and HTTP API should use these functions.
"""
from models.decision import ListingDecision from models.decision import ListingDecision
from repositories.decision_repository import DecisionRepository from repositories.decision_repository import DecisionRepository
@ -13,47 +17,53 @@ def set_decision(
listing_type: str, listing_type: str,
decision: str, decision: str,
) -> ListingDecision: ) -> ListingDecision:
"""Set or update a like/dislike decision for a listing."""
if decision not in VALID_DECISIONS: if decision not in VALID_DECISIONS:
raise ValueError( raise ValueError(
f"Invalid decision '{decision}'. Must be one of: {VALID_DECISIONS}" f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}"
) )
if listing_type not in VALID_LISTING_TYPES: if listing_type not in VALID_LISTING_TYPES:
raise ValueError( raise ValueError(
f"Invalid listing_type '{listing_type}'. Must be one of: {VALID_LISTING_TYPES}" f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
) )
return repository.upsert_decision( return repository.upsert_decision(user_id, listing_id, listing_type, decision)
user_id=user_id,
listing_id=listing_id,
listing_type=listing_type,
decision=decision,
)
def get_decisions( def get_user_decisions(
repository: DecisionRepository, repository: DecisionRepository,
user_id: int, user_id: int,
) -> list[ListingDecision]: ) -> list[ListingDecision]:
"""Get all decisions for a user."""
return repository.get_decisions_for_user(user_id) return repository.get_decisions_for_user(user_id)
def clear_decision( def remove_decision(
repository: DecisionRepository, repository: DecisionRepository,
user_id: int, user_id: int,
listing_id: int, listing_id: int,
listing_type: str, listing_type: str,
) -> bool: ) -> bool:
return repository.delete_decision( """Remove a decision (un-like/un-dislike). Returns False if not found."""
user_id=user_id, if listing_type not in VALID_LISTING_TYPES:
listing_id=listing_id, raise ValueError(
listing_type=listing_type, f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
) )
return repository.delete_decision(user_id, listing_id, listing_type)
def get_disliked_listing_ids( def get_disliked_ids(
repository: DecisionRepository, repository: DecisionRepository,
user_id: int, user_id: int,
listing_type: str, listing_type: str,
) -> set[int]: ) -> set[int]:
return repository.get_disliked_listing_ids( """Get all disliked listing IDs for a user and listing type."""
user_id=user_id, listing_type=listing_type return repository.get_disliked_listing_ids(user_id, listing_type)
)
def get_liked_ids(
repository: DecisionRepository,
user_id: int,
listing_type: str,
) -> set[int]:
"""Get all liked listing IDs for a user and listing type."""
return repository.get_liked_listing_ids(user_id, listing_type)

View file

@ -54,6 +54,7 @@ def convert_row_to_geojson(row: dict[str, Any], listing_type: str = "RENT") -> d
last_seen_str = str(last_seen_val) last_seen_str = str(last_seen_val)
properties: dict[str, Any] = { properties: dict[str, Any] = {
"id": row['id'],
"listing_type": listing_type, "listing_type": listing_type,
"city": "London", "city": "London",
"country": "United Kingdom", "country": "United Kingdom",
@ -98,6 +99,7 @@ def convert_to_geojson_feature(listing: RentListing | BuyListing) -> dict[str, A
listing_type = "RENT" if isinstance(listing, RentListing) else "BUY" listing_type = "RENT" if isinstance(listing, RentListing) else "BUY"
properties: dict[str, Any] = { properties: dict[str, Any] = {
"id": listing.id,
"listing_type": listing_type, "listing_type": listing_type,
"city": "London", "city": "London",
"country": "United Kingdom", "country": "United Kingdom",